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

This commit is contained in:
Ibrahim 2024-09-11 14:57:19 -04:00 committed by GitHub
parent 4432b6b420
commit 7a96406a11
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
80 changed files with 4119 additions and 253 deletions

View File

@ -1,7 +1,8 @@
import React from 'react';
import { useAppConfig } from '@state';
import { Toolbox } from '@ohif/ui';
import { Toolbox as NewToolbox } from '@ohif/ui-next';
import { Toolbox as OldToolbox } from '@ohif/ui';
import PanelSegmentation from './panels/PanelSegmentation';
const getPanelModule = ({
@ -13,7 +14,27 @@ const getPanelModule = ({
}: withAppTypes) => {
const { customizationService } = servicesManager.services;
const wrappedPanelSegmentation = configuration => {
const wrappedPanelSegmentation = ({ configuration, renderHeader, getCloseIcon, tab }) => {
const [appConfig] = useAppConfig();
return (
<PanelSegmentation
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
configuration={{
...configuration,
disableEditing: appConfig.disableEditing,
...customizationService.get('segmentation.panel'),
}}
renderHeader={renderHeader}
getCloseIcon={getCloseIcon}
tab={tab}
/>
);
};
const wrappedPanelSegmentationNoHeader = ({ configuration }) => {
const [appConfig] = useAppConfig();
return (
@ -30,9 +51,16 @@ const getPanelModule = ({
);
};
const wrappedPanelSegmentationWithTools = configuration => {
const wrappedPanelSegmentationWithTools = ({
configuration,
renderHeader,
getCloseIcon,
tab,
}) => {
const [appConfig] = useAppConfig();
const Toolbox = appConfig.useExperimentalUI ? NewToolbox : OldToolbox;
return (
<>
<Toolbox
@ -44,6 +72,9 @@ const getPanelModule = ({
configuration={{
...configuration,
}}
renderHeader={renderHeader}
getCloseIcon={getCloseIcon}
tab={tab}
/>
<PanelSegmentation
commandsManager={commandsManager}
@ -74,6 +105,13 @@ const getPanelModule = ({
label: 'Segmentation',
component: wrappedPanelSegmentationWithTools,
},
{
name: 'panelSegmentationNoHeader',
iconName: 'tab-segmentation',
iconLabel: 'Segmentation',
label: 'Segmentation',
component: wrappedPanelSegmentationNoHeader,
},
];
};

View File

@ -6,6 +6,7 @@ import { SegmentationPanelMode } from '../types/segmentation';
import callInputDialog from './callInputDialog';
import callColorPickerDialog from './colorPickerDialog';
import { useTranslation } from 'react-i18next';
import { Separator } from '@ohif/ui-next';
const components = {
[SegmentationPanelMode.Expanded]: SegmentationGroupTableExpanded,
@ -17,6 +18,9 @@ export default function PanelSegmentation({
commandsManager,
extensionManager,
configuration,
renderHeader,
getCloseIcon,
tab,
}: withAppTypes) {
const {
segmentationService,
@ -319,52 +323,76 @@ export default function PanelSegmentation({
: onSegmentationAdd;
return (
<SegmentationGroupTableComponent
title={t('Segmentations')}
segmentations={segmentations}
disableEditing={configuration.disableEditing}
activeSegmentationId={selectedSegmentationId || ''}
onSegmentationAdd={onSegmentationAddWrapper}
addSegmentationClassName={addSegmentationClassName}
showAddSegment={allowAddSegment}
onSegmentationClick={onSegmentationClick}
onSegmentationDelete={onSegmentationDelete}
onSegmentationDownload={onSegmentationDownload}
onSegmentationDownloadRTSS={onSegmentationDownloadRTSS}
storeSegmentation={storeSegmentation}
onSegmentationEdit={onSegmentationEdit}
onSegmentClick={onSegmentClick}
onSegmentEdit={onSegmentEdit}
onSegmentAdd={onSegmentAdd}
onSegmentColorClick={onSegmentColorClick}
onSegmentDelete={onSegmentDelete}
onToggleSegmentVisibility={onToggleSegmentVisibility}
onToggleSegmentLock={onToggleSegmentLock}
onToggleSegmentationVisibility={onToggleSegmentationVisibility}
showDeleteSegment={true}
segmentationConfig={{ initialConfig: segmentationConfiguration }}
setRenderOutline={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderOutline', value)
}
setOutlineOpacityActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineOpacity', value)
}
setRenderFill={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderFill', value)
}
setRenderInactiveSegmentations={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderInactiveSegmentations', value)
}
setOutlineWidthActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineWidthActive', value)
}
setFillAlpha={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlpha', value)
}
setFillAlphaInactive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlphaInactive', value)
}
/>
<>
{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"
/>
</>
)}
<SegmentationGroupTableComponent
title={t('Segmentations')}
segmentations={segmentations}
disableEditing={configuration.disableEditing}
activeSegmentationId={selectedSegmentationId || ''}
onSegmentationAdd={onSegmentationAddWrapper}
addSegmentationClassName={addSegmentationClassName}
showAddSegment={allowAddSegment}
onSegmentationClick={onSegmentationClick}
onSegmentationDelete={onSegmentationDelete}
onSegmentationDownload={onSegmentationDownload}
onSegmentationDownloadRTSS={onSegmentationDownloadRTSS}
storeSegmentation={storeSegmentation}
onSegmentationEdit={onSegmentationEdit}
onSegmentClick={onSegmentClick}
onSegmentEdit={onSegmentEdit}
onSegmentAdd={onSegmentAdd}
onSegmentColorClick={onSegmentColorClick}
onSegmentDelete={onSegmentDelete}
onToggleSegmentVisibility={onToggleSegmentVisibility}
onToggleSegmentLock={onToggleSegmentLock}
onToggleSegmentationVisibility={onToggleSegmentationVisibility}
showDeleteSegment={true}
segmentationConfig={{ initialConfig: segmentationConfiguration }}
setRenderOutline={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderOutline', value)
}
setOutlineOpacityActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineOpacity', value)
}
setRenderFill={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'renderFill', value)
}
setRenderInactiveSegmentations={value =>
_setSegmentationConfiguration(
selectedSegmentationId,
'renderInactiveSegmentations',
value
)
}
setOutlineWidthActive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'outlineWidthActive', value)
}
setFillAlpha={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlpha', value)
}
setFillAlphaInactive={value =>
_setSegmentationConfiguration(selectedSegmentationId, 'fillAlphaInactive', value)
}
/>
</>
);
}

View File

@ -1,20 +1,28 @@
import React from 'react';
import { DynamicDataPanel } from './panels';
import { Toolbox } from '@ohif/ui';
import { Toolbox as NewToolbox } from '@ohif/ui-next';
import { Toolbox as OldToolbox } from '@ohif/ui';
import { useAppConfig } from '@state';
import DynamicExport from './panels/DynamicExport';
function getPanelModule({ commandsManager, extensionManager, servicesManager }) {
const wrappedDynamicDataPanel = () => {
const wrappedDynamicDataPanel = ({ renderHeader, getCloseIcon, tab }) => {
return (
<DynamicDataPanel
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
renderHeader={renderHeader}
getCloseIcon={getCloseIcon}
tab={tab}
/>
);
};
const wrappedDynamicToolbox = () => {
const wrappedDynamicToolbox = ({ renderHeader, getCloseIcon, tab }) => {
const [appConfig] = useAppConfig();
const Toolbox = appConfig.useExperimentalUI ? NewToolbox : OldToolbox;
return (
<>
<Toolbox
@ -23,6 +31,9 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager })
extensionManager={extensionManager}
buttonSectionId="dynamic-toolbox"
title="Threshold Tools"
renderHeader={renderHeader}
getCloseIcon={getCloseIcon}
tab={tab}
/>
</>
);

View File

@ -1,17 +1,44 @@
import React from 'react';
import PanelGenerateImage from './PanelGenerateImage';
import { Separator } from '@ohif/ui-next';
function DynamicDataPanel({ servicesManager, commandsManager }) {
function DynamicDataPanel({
servicesManager,
commandsManager,
renderHeader,
getCloseIcon,
tab,
}: withAppTypes) {
return (
<div
className="flex flex-col text-white"
data-cy={'dynamic-volume-panel'}
>
<PanelGenerateImage
commandsManager={commandsManager}
servicesManager={servicesManager}
></PanelGenerateImage>
</div>
<>
{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"
/>
</>
)}
<div
className="flex flex-col text-white"
data-cy={'dynamic-volume-panel'}
>
<PanelGenerateImage
commandsManager={commandsManager}
servicesManager={servicesManager}
></PanelGenerateImage>
</div>
</>
);
}

View File

@ -1,5 +1,7 @@
import React, { useEffect, useState, useCallback } from 'react';
import { SidePanel } from '@ohif/ui';
import { SidePanel as NewSidePanel } from '@ohif/ui-next';
import { SidePanel as OldSidePanel } from '@ohif/ui';
import { useAppConfig } from '@state';
import { Types } from '@ohif/core';
export type SidePanelWithServicesProps = {
@ -26,6 +28,7 @@ const SidePanelWithServices = ({
const [hasBeenOpened, setHasBeenOpened] = useState(false);
const [activeTabIndex, setActiveTabIndex] = useState(activeTabIndexProp);
const [tabs, setTabs] = useState(tabsProp ?? panelService.getPanels(side));
const [appConfig] = useAppConfig();
const handleSidePanelOpen = useCallback(() => {
setHasBeenOpened(true);
@ -75,6 +78,8 @@ const SidePanelWithServices = ({
};
}, [tabs, hasBeenOpened, panelService]);
const SidePanel = appConfig?.useExperimentalUI ? NewSidePanel : OldSidePanel;
return (
<SidePanel
{...props}

View File

@ -17,6 +17,7 @@ import createReportDialogPrompt, {
} from './createReportDialogPrompt';
import createReportAsync from '../Actions/createReportAsync';
import findSRWithSameSeriesDescription from '../utils/findSRWithSameSeriesDescription';
import { Separator } from '@ohif/ui-next';
const { downloadCSVReport } = utils;
@ -24,6 +25,9 @@ export default function PanelMeasurementTable({
servicesManager,
commandsManager,
extensionManager,
renderHeader,
getCloseIcon,
tab,
}: withAppTypes): React.FunctionComponent {
const { t } = useTranslation('MeasurementTable');
@ -212,6 +216,24 @@ export default function PanelMeasurementTable({
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"
/>
</>
)}
<div
className="ohif-scrollbar overflow-y-auto overflow-x-hidden"
data-cy={'measurements-panel'}

View File

@ -1,8 +1,14 @@
import React, { useState, useEffect, useRef } from 'react';
import PropTypes from 'prop-types';
import { StudyBrowser, useImageViewer, useViewportGrid } from '@ohif/ui';
import { useImageViewer, useViewportGrid } from '@ohif/ui';
import { StudyBrowser as NewStudyBrowser } from '@ohif/ui-next';
import { StudyBrowser as OldStudyBrowser } from '@ohif/ui';
import { utils } from '@ohif/core';
import { useAppConfig } from '@state';
import { useNavigate } from 'react-router-dom';
import { Separator } from '@ohif/ui-next';
import { PanelStudyBrowserHeader } from './PanelStudyBrowserHeader';
import { defaultActionIcons, defaultViewPresets } from './constants';
const { sortStudyInstances, formatDate, createStudyBrowserTabs } = utils;
@ -16,10 +22,14 @@ function PanelStudyBrowser({
getStudiesForPatientByMRN,
requestDisplaySetCreationForStudy,
dataSource,
renderHeader,
getCloseIcon,
tab,
}: withAppTypes) {
const { hangingProtocolService, displaySetService, uiNotificationService } =
const { hangingProtocolService, displaySetService, uiNotificationService, customizationService } =
servicesManager.services;
const navigate = useNavigate();
const [appConfig] = useAppConfig();
// Normally you nest the components so the tree isn't so deep, and the data
// doesn't have to have such an intense shape. This works well enough for now.
@ -36,6 +46,31 @@ function PanelStudyBrowser({
const [displaySets, setDisplaySets] = useState([]);
const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({});
const [viewPresets, setViewPresets] = useState(
customizationService.getCustomization('studyBrowser.viewPresets')?.value || defaultViewPresets
);
const [actionIcons, setActionIcons] = useState(defaultActionIcons);
// multiple can be true or false
const updateActionIconValue = actionIcon => {
actionIcon.value = !actionIcon.value;
const newActionIcons = [...actionIcons];
setActionIcons(newActionIcons);
};
// only one is true at a time
const updateViewPresetValue = viewPreset => {
if (!viewPreset) {
return;
}
const newViewPresets = viewPresets.map(preset => {
preset.selected = preset.id === viewPreset.id;
return preset;
});
setViewPresets(newViewPresets);
};
const onDoubleClickThumbnailHandler = displaySetInstanceUID => {
let updatedViewports = [];
const viewportId = activeViewportId;
@ -164,9 +199,10 @@ function PanelStudyBrowser({
const SubscriptionDisplaySetsAdded = displaySetService.subscribe(
displaySetService.EVENTS.DISPLAY_SETS_ADDED,
data => {
if (!hasLoadedViewports) {
return;
}
// for some reason this breaks thumbnail loading
// if (!hasLoadedViewports) {
// return;
// }
const { displaySetsAdded, options } = data;
displaySetsAdded.forEach(async dSet => {
const newImageSrcEntry = {};
@ -236,7 +272,7 @@ function PanelStudyBrowser({
const shouldCollapseStudy = expandedStudyInstanceUIDs.includes(StudyInstanceUID);
const updatedExpandedStudyInstanceUIDs = shouldCollapseStudy
? // eslint-disable-next-line prettier/prettier
[...expandedStudyInstanceUIDs.filter(stdyUid => stdyUid !== StudyInstanceUID)]
[...expandedStudyInstanceUIDs.filter(stdyUid => stdyUid !== StudyInstanceUID)]
: [...expandedStudyInstanceUIDs, StudyInstanceUID];
setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs);
@ -249,19 +285,42 @@ function PanelStudyBrowser({
const activeDisplaySetInstanceUIDs = viewports.get(activeViewportId)?.displaySetInstanceUIDs;
const StudyBrowser = appConfig?.useExperimentalUI ? NewStudyBrowser : OldStudyBrowser;
return (
<StudyBrowser
tabs={tabs}
servicesManager={servicesManager}
activeTabName={activeTabName}
onDoubleClickThumbnail={onDoubleClickThumbnailHandler}
activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs}
expandedStudyInstanceUIDs={expandedStudyInstanceUIDs}
onClickStudy={_handleStudyClick}
onClickTab={clickedTabName => {
setActiveTabName(clickedTabName);
}}
/>
<>
{renderHeader && (
<>
<PanelStudyBrowserHeader
tab={tab}
getCloseIcon={getCloseIcon}
viewPresets={viewPresets}
updateViewPresetValue={updateViewPresetValue}
actionIcons={actionIcons}
updateActionIconValue={updateActionIconValue}
/>
<Separator
orientation="horizontal"
className="bg-black"
thickness="2px"
/>
</>
)}
<StudyBrowser
tabs={tabs}
servicesManager={servicesManager}
activeTabName={activeTabName}
onDoubleClickThumbnail={onDoubleClickThumbnailHandler}
activeDisplaySetInstanceUIDs={activeDisplaySetInstanceUIDs}
expandedStudyInstanceUIDs={expandedStudyInstanceUIDs}
onClickStudy={_handleStudyClick}
onClickTab={clickedTabName => {
setActiveTabName(clickedTabName);
}}
showSettings={actionIcons.find(icon => icon.id === 'settings').value}
viewPresets={viewPresets}
/>
</>
);
}

View File

@ -0,0 +1,72 @@
import React from 'react';
import { ToggleGroup, ToggleGroupItem } from '@ohif/ui-next';
import { Icons } from '@ohif/ui-next';
import { actionIcon, viewPreset } from './types';
function PanelStudyBrowserHeader({
tab,
getCloseIcon,
viewPresets,
updateViewPresetValue,
actionIcons,
updateActionIconValue,
}: {
tab: any;
getCloseIcon: () => JSX.Element;
viewPresets: viewPreset[];
updateViewPresetValue: (viewPreset: viewPreset) => void;
actionIcons: actionIcon[];
updateActionIconValue: (actionIcon: actionIcon) => void;
}) {
return (
<>
<div className="bg-muted flex h-[40px] select-none rounded-t p-2">
<div className={'flex h-[24px] w-full select-none justify-center self-center text-[14px]'}>
<div className="flex w-full items-center justify-between">
<div className="flex h-full items-center justify-center">
<ToggleGroup
type="single"
value={viewPresets.filter(preset => preset.selected)[0].id}
onValueChange={value => {
const selectedViewPreset = viewPresets.find(preset => preset.id === value);
updateViewPresetValue(selectedViewPreset);
}}
>
{viewPresets.map((viewPreset: viewPreset, index) => (
<ToggleGroupItem
key={index}
aria-label={viewPreset.id}
value={viewPreset.id}
className="text-actions-primary"
>
{React.createElement(Icons[viewPreset.iconName] || Icons.MissingIcon)}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="text-muted-foreground flex items-center justify-center">
{' '}
<span>{tab.label}</span>{' '}
</div>
<div className="mr-[30px] flex items-center justify-center">
<div className="flex items-center space-x-1">
{actionIcons.map((icon: actionIcon, index) =>
React.createElement(Icons[icon.iconName] || Icons.MissingIcon, {
key: index,
onClick: () => updateActionIconValue(icon),
className: `cursor-pointer`,
})
)}
</div>
</div>
</div>
</div>
{getCloseIcon()}
</div>
</>
);
}
export { PanelStudyBrowserHeader };

View File

@ -0,0 +1,11 @@
import type { actionIcon } from '../types/actionsIcon';
const defaultActionIcons = [
{
id: 'settings',
iconName: 'Settings',
value: false,
},
] as actionIcon[];
export { defaultActionIcons };

View File

@ -0,0 +1,4 @@
import { defaultActionIcons } from './actionIcons';
import { defaultViewPresets } from './viewPresets';
export { defaultActionIcons, defaultViewPresets };

View File

@ -0,0 +1,16 @@
import type { viewPreset } from '../types/viewPreset';
const defaultViewPresets = [
{
id: 'list',
iconName: 'ListView',
selected: false,
},
{
id: 'thumbnails',
iconName: 'ThumbnailView',
selected: true,
},
] as viewPreset[];
export { defaultViewPresets };

View File

@ -0,0 +1,7 @@
type actionIcon = {
id: string;
iconName: string;
value: boolean;
};
export type { actionIcon };

View File

@ -0,0 +1,4 @@
import type { actionIcon } from './actionIcon';
import type { viewPreset } from './viewPreset';
export type { actionIcon, viewPreset };

View File

@ -0,0 +1,7 @@
type viewPreset = {
id: string;
iconName: string;
selected: boolean;
};
export type { viewPreset };

View File

@ -1,7 +1,7 @@
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
//
import PanelStudyBrowser from './PanelStudyBrowser';
import PanelStudyBrowser from './StudyBrowser/PanelStudyBrowser';
import getImageSrcFromImageId from './getImageSrcFromImageId';
import getStudiesForPatientByMRN from './getStudiesForPatientByMRN';
import requestDisplaySetCreationForStudy from './requestDisplaySetCreationForStudy';
@ -13,7 +13,14 @@ import requestDisplaySetCreationForStudy from './requestDisplaySetCreationForStu
* @param {object} commandsManager
* @param {object} extensionManager
*/
function WrappedPanelStudyBrowser({ commandsManager, extensionManager, servicesManager }) {
function WrappedPanelStudyBrowser({
commandsManager,
extensionManager,
servicesManager,
renderHeader,
getCloseIcon,
tab,
}) {
// TODO: This should be made available a different way; route should have
// already determined our datasource
const dataSource = extensionManager.getDataSources()[0];
@ -34,6 +41,9 @@ function WrappedPanelStudyBrowser({ commandsManager, extensionManager, servicesM
getImageSrc={_getImageSrcFromImageId}
getStudiesForPatientByMRN={_getStudiesForPatientByMRN}
requestDisplaySetCreationForStudy={_requestDisplaySetCreationForStudy}
renderHeader={renderHeader}
getCloseIcon={getCloseIcon}
tab={tab}
/>
);
}

View File

@ -1,4 +1,4 @@
import PanelStudyBrowser from './PanelStudyBrowser';
import PanelStudyBrowser from './StudyBrowser/PanelStudyBrowser';
import WrappedPanelStudyBrowser from './WrappedPanelStudyBrowser';
import PanelMeasurementTable from './PanelMeasurementTable';
import createReportDialogPrompt from './createReportDialogPrompt';

View File

@ -184,6 +184,22 @@ export default function getCustomizationModule({ servicesManager, extensionManag
},
],
},
{
id: 'studyBrowser.viewPresets',
// change your default selected preset here
value: [
{
id: 'list',
iconName: 'ListView',
selected: false,
},
{
id: 'thumbnails',
iconName: 'ThumbnailView',
selected: true,
},
],
},
],
},
];

View File

@ -8,12 +8,15 @@ import i18n from 'i18next';
// - show errors in UI for thumbnails if promise fails
function getPanelModule({ commandsManager, extensionManager, servicesManager }) {
const wrappedMeasurementPanel = () => {
const wrappedMeasurementPanel = ({ renderHeader, getCloseIcon, tab }) => {
return (
<PanelMeasurementTable
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
renderHeader={renderHeader}
getCloseIcon={getCloseIcon}
tab={tab}
/>
);
};
@ -24,11 +27,14 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager })
iconName: 'tab-studies',
iconLabel: 'Studies',
label: i18n.t('SidePanel:Studies'),
component: WrappedPanelStudyBrowser.bind(null, {
commandsManager,
extensionManager,
servicesManager,
}),
component: props => (
<WrappedPanelStudyBrowser
{...props}
commandsManager={commandsManager}
extensionManager={extensionManager}
servicesManager={servicesManager}
/>
),
},
{
name: 'measurements',

View File

@ -8,6 +8,7 @@ import dcmjs from 'dcmjs';
import callInputDialog from '../../utils/callInputDialog';
import constructSR from '../../utils/constructSR';
import { saveByteArray } from '../../utils/saveByteArray';
import { Separator } from '@ohif/ui-next';
let saving = false;
const { datasetToBuffer } = dcmjs.data;
@ -56,6 +57,9 @@ interface IMicroscopyPanelProps extends WithTranslation {
servicesManager: AppTypes.ServicesManager;
extensionManager: ExtensionManager;
commandsManager: CommandsManager;
renderHeader?: boolean;
getCloseIcon?: PropTypes.func;
tab: PropTypes.object;
}
/**
@ -328,6 +332,20 @@ function MicroscopyPanel(props: IMicroscopyPanelProps) {
return (
<>
{props.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>{props.tab.label}</span>
</div>
</div>
{props.getCloseIcon()}
</div>
<Separator orientation="horizontal" className="bg-black" thickness="2px" />
</>
)}{' '}
<div
className="ohif-scrollbar overflow-y-auto overflow-x-hidden"
data-cy={'measurements-panel'}

View File

@ -13,7 +13,7 @@ export default function getPanelModule({
extensionManager,
servicesManager,
}: Types.Extensions.ExtensionParams) {
const wrappedMeasurementPanel = () => {
const wrappedMeasurementPanel = ({ renderHeader, getCloseIcon, tab }) => {
const [{ activeViewportId, viewports }] = useViewportGrid();
return (
@ -25,6 +25,9 @@ export default function getPanelModule({
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
renderHeader={renderHeader}
getCloseIcon={getCloseIcon}
tab={tab}
/>
);
};

View File

@ -1,11 +1,13 @@
import { Types } from '@ohif/core';
import { PanelMeasurementTableTracking, PanelStudyBrowserTracking } from './panels';
import i18n from 'i18next';
import React from 'react';
// TODO:
// - No loading UI exists yet
// - cancel promises when component is destroyed
// - show errors in UI for thumbnails if promise fails
function getPanelModule({ commandsManager, extensionManager, servicesManager }): Types.Panel[] {
return [
{
@ -13,11 +15,14 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager }):
iconName: 'tab-studies',
iconLabel: 'Studies',
label: i18n.t('SidePanel:Studies'),
component: PanelStudyBrowserTracking.bind(null, {
commandsManager,
extensionManager,
servicesManager,
}),
component: props => (
<PanelStudyBrowserTracking
{...props}
commandsManager={commandsManager}
extensionManager={extensionManager}
servicesManager={servicesManager}
/>
),
},
{
@ -25,11 +30,14 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager }):
iconName: 'tab-linear',
iconLabel: 'Measure',
label: i18n.t('SidePanel:Measurements'),
component: PanelMeasurementTableTracking.bind(null, {
commandsManager,
extensionManager,
servicesManager,
}),
component: props => (
<PanelMeasurementTableTracking
{...props}
commandsManager={commandsManager}
extensionManager={extensionManager}
servicesManager={servicesManager}
/>
),
},
];
}

View File

@ -7,6 +7,7 @@ import { useAppConfig } from '@state';
import { useTrackedMeasurements } from '../../getContextModule';
import debounce from 'lodash.debounce';
import { useTranslation } from 'react-i18next';
import { Separator } from '@ohif/ui-next';
const { downloadCSVReport } = utils;
const { formatDate } = utils;
@ -18,7 +19,13 @@ const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = {
description: '', // 'CHEST/ABD/PELVIS W CONTRAST',
};
function PanelMeasurementTableTracking({ servicesManager, extensionManager }: withAppTypes) {
function PanelMeasurementTableTracking({
servicesManager,
extensionManager,
renderHeader,
getCloseIcon,
tab,
}: withAppTypes) {
const [viewportGrid] = useViewportGrid();
const { t } = useTranslation('MeasurementTable');
const [measurementChangeTimestamp, setMeasurementsUpdated] = useState(Date.now().toString());
@ -176,6 +183,24 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }: wi
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"
/>
</>
)}
<div
className="invisible-scrollbar overflow-y-auto overflow-x-hidden"
ref={measurementsPanelRef}

View File

@ -3,8 +3,15 @@ import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import PropTypes from 'prop-types';
import { utils } from '@ohif/core';
import { StudyBrowser, useImageViewer, useViewportGrid, Dialog, ButtonEnums } from '@ohif/ui';
import { useImageViewer, useViewportGrid, Dialog, ButtonEnums } from '@ohif/ui';
import { StudyBrowser as NewStudyBrowser } from '@ohif/ui-next';
import { StudyBrowser as OldStudyBrowser } from '@ohif/ui';
import { useAppConfig } from '@state';
import { useTrackedMeasurements } from '../../getContextModule';
import { Separator } from '@ohif/ui-next';
import { PanelStudyBrowserTrackingHeader } from './PanelStudyBrowserTrackingHeader';
import { defaultActionIcons, defaultViewPresets } from './constants';
const { formatDate, createStudyBrowserTabs } = utils;
@ -18,6 +25,9 @@ function PanelStudyBrowserTracking({
getStudiesForPatientByMRN,
requestDisplaySetCreationForStudy,
dataSource,
renderHeader,
getCloseIcon,
tab,
}: withAppTypes) {
const {
displaySetService,
@ -26,10 +36,12 @@ function PanelStudyBrowserTracking({
uiNotificationService,
measurementService,
studyPrefetcherService,
customizationService,
} = servicesManager.services;
const navigate = useNavigate();
const { t } = useTranslation('Common');
const [appConfig] = useAppConfig();
// Normally you nest the components so the tree isn't so deep, and the data
// doesn't have to have such an intense shape. This works well enough for now.
@ -49,6 +61,29 @@ function PanelStudyBrowserTracking({
const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({});
const [jumpToDisplaySet, setJumpToDisplaySet] = useState(null);
const [viewPresets, setViewPresets] = useState(
customizationService.getCustomization('studyBrowser.viewPresets')?.value || defaultViewPresets
);
const [actionIcons, setActionIcons] = useState(defaultActionIcons);
const updateActionIconValue = actionIcon => {
actionIcon.value = !actionIcon.value;
const newActionIcons = [...actionIcons];
setActionIcons(newActionIcons);
};
const updateViewPresetValue = viewPreset => {
if (!viewPreset) {
return;
}
const newViewPresets = viewPresets.map(preset => {
preset.selected = preset.id === viewPreset.id;
return preset;
});
setViewPresets(newViewPresets);
};
const onDoubleClickThumbnailHandler = displaySetInstanceUID => {
let updatedViewports = [];
const viewportId = activeViewportId;
@ -431,23 +466,46 @@ function PanelStudyBrowserTracking({
});
};
const StudyBrowser = appConfig.useExperimentalUI ? NewStudyBrowser : OldStudyBrowser;
return (
<StudyBrowser
tabs={tabs}
servicesManager={servicesManager}
activeTabName={activeTabName}
expandedStudyInstanceUIDs={expandedStudyInstanceUIDs}
onClickStudy={_handleStudyClick}
onClickTab={clickedTabName => {
setActiveTabName(clickedTabName);
}}
onClickUntrack={displaySetInstanceUID => {
onClickUntrack(displaySetInstanceUID);
}}
onClickThumbnail={() => {}}
onDoubleClickThumbnail={onDoubleClickThumbnailHandler}
activeDisplaySetInstanceUIDs={activeViewportDisplaySetInstanceUIDs}
/>
<>
{renderHeader && (
<>
<PanelStudyBrowserTrackingHeader
tab={tab}
getCloseIcon={getCloseIcon}
viewPresets={viewPresets}
updateViewPresetValue={updateViewPresetValue}
actionIcons={actionIcons}
updateActionIconValue={updateActionIconValue}
/>
<Separator
orientation="horizontal"
className="bg-black"
thickness="2px"
/>
</>
)}
<StudyBrowser
tabs={tabs}
servicesManager={servicesManager}
activeTabName={activeTabName}
expandedStudyInstanceUIDs={expandedStudyInstanceUIDs}
onClickStudy={_handleStudyClick}
onClickTab={clickedTabName => {
setActiveTabName(clickedTabName);
}}
onClickUntrack={displaySetInstanceUID => {
onClickUntrack(displaySetInstanceUID);
}}
onClickThumbnail={() => {}}
onDoubleClickThumbnail={onDoubleClickThumbnailHandler}
activeDisplaySetInstanceUIDs={activeViewportDisplaySetInstanceUIDs}
showSettings={actionIcons.find(icon => icon.id === 'settings').value}
viewPresets={viewPresets}
/>
</>
);
}

View File

@ -0,0 +1,72 @@
import React from 'react';
import { ToggleGroup, ToggleGroupItem } from '@ohif/ui-next';
import { Icons } from '@ohif/ui-next';
import { actionIcon, viewPreset } from './types';
function PanelStudyBrowserTrackingHeader({
tab,
getCloseIcon,
viewPresets,
updateViewPresetValue,
actionIcons,
updateActionIconValue,
}: {
tab: any;
getCloseIcon: () => JSX.Element;
viewPresets: viewPreset[];
updateViewPresetValue: (viewPreset: viewPreset) => void;
actionIcons: actionIcon[];
updateActionIconValue: (actionIcon: actionIcon) => void;
}) {
return (
<>
<div className="bg-muted flex h-[40px] select-none rounded-t p-2">
<div className={'flex h-[24px] w-full select-none justify-center self-center text-[14px]'}>
<div className="flex w-full items-center justify-between">
<div className="flex h-full items-center justify-center">
<ToggleGroup
type="single"
value={viewPresets.filter(preset => preset.selected)[0].id}
onValueChange={value => {
const selectedViewPreset = viewPresets.find(preset => preset.id === value);
updateViewPresetValue(selectedViewPreset);
}}
>
{viewPresets.map((viewPreset: viewPreset, index) => (
<ToggleGroupItem
key={index}
aria-label={viewPreset.id}
value={viewPreset.id}
className="text-actions-primary"
>
{React.createElement(Icons[viewPreset.iconName] || Icons.MissingIcon)}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="text-muted-foreground flex items-center justify-center">
{' '}
<span>{tab.label}</span>{' '}
</div>
<div className="mr-[30px] flex items-center justify-center">
<div className="flex items-center space-x-1">
{actionIcons.map((icon: actionIcon, index) =>
React.createElement(Icons[icon.iconName] || Icons.MissingIcon, {
key: index,
onClick: () => updateActionIconValue(icon),
className: `cursor-pointer`,
})
)}
</div>
</div>
</div>
</div>
{getCloseIcon()}
</div>
</>
);
}
export { PanelStudyBrowserTrackingHeader };

View File

@ -0,0 +1,11 @@
import type { actionIcon } from '../PanelStudyBrowserTracking/types/actionsIcon';
const defaultActionIcons = [
{
id: 'settings',
iconName: 'Settings',
value: false,
},
] as actionIcon[];
export { defaultActionIcons };

View File

@ -0,0 +1,4 @@
import { defaultActionIcons } from './actionIcons';
import { defaultViewPresets } from './viewPresets';
export { defaultActionIcons, defaultViewPresets };

View File

@ -0,0 +1,16 @@
import type { viewPreset } from '../PanelStudyBrowserTracking/types/viewPreset';
const defaultViewPresets = [
{
id: 'list',
iconName: 'ListView',
selected: false,
},
{
id: 'thumbnails',
iconName: 'ThumbnailView',
selected: true,
},
] as viewPreset[];
export { defaultViewPresets };

View File

@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import PropTypes from 'prop-types';
//
import PanelStudyBrowserTracking from './PanelStudyBrowserTracking';
@ -25,6 +25,9 @@ function WrappedPanelStudyBrowserTracking({
commandsManager,
extensionManager,
servicesManager,
renderHeader,
getCloseIcon,
tab,
}: withAppTypes) {
const dataSource = extensionManager.getActiveDataSource()[0];
@ -46,6 +49,9 @@ function WrappedPanelStudyBrowserTracking({
getImageSrc={_getImageSrcFromImageId}
getStudiesForPatientByMRN={_getStudiesForPatientByMRN}
requestDisplaySetCreationForStudy={_requestDisplaySetCreationForStudy}
renderHeader={renderHeader}
getCloseIcon={getCloseIcon}
tab={tab}
/>
);
}

View File

@ -0,0 +1,7 @@
type actionIcon = {
id: string;
iconName: string;
value: boolean;
};
export type { actionIcon };

View File

@ -0,0 +1,4 @@
import type { actionIcon } from './actionIcon';
import type { viewPreset } from './viewPreset';
export type { actionIcon, viewPreset };

View File

@ -0,0 +1,7 @@
type viewPreset = {
id: string;
iconName: string;
selected: boolean;
};
export type { viewPreset };

View File

@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import { PanelSection, Input, Button } from '@ohif/ui';
import { DicomMetadataStore } from '@ohif/core';
import { useTranslation } from 'react-i18next';
import { Separator } from '@ohif/ui-next';
const DEFAULT_MEATADATA = {
PatientWeight: null,
@ -22,7 +23,13 @@ const DEFAULT_MEATADATA = {
* @param param0
* @returns
*/
export default function PanelPetSUV({ servicesManager, commandsManager }: withAppTypes) {
export default function PanelPetSUV({
servicesManager,
commandsManager,
renderHeader,
getCloseIcon,
tab,
}: withAppTypes) {
const { t } = useTranslation('PanelSUV');
const { displaySetService, toolGroupService, toolbarService, hangingProtocolService } =
servicesManager.services;
@ -125,104 +132,127 @@ export default function PanelPetSUV({ servicesManager, commandsManager }: withAp
}, 0);
}
return (
<div className="ohif-scrollbar flex min-h-0 flex-auto select-none flex-col justify-between overflow-auto">
<div className="flex min-h-0 flex-1 flex-col bg-black text-[13px] font-[300]">
<PanelSection title={t('Patient Information')}>
<div className="flex flex-col">
<div className="bg-primary-dark flex flex-col gap-4 p-2">
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Patient Sex')}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.PatientSex || ''}
onChange={e => {
handleMetadataChange({
PatientSex: e.target.value,
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Weight')}
labelChildren={<span className="text-aqua-pale"> kg</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.PatientWeight || ''}
onChange={e => {
handleMetadataChange({
PatientWeight: e.target.value,
});
}}
id="weight-input"
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Total Dose')}
labelChildren={<span className="text-aqua-pale"> bq</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose || ''}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadionuclideTotalDose: e.target.value,
},
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Half Life')}
labelChildren={<span className="text-aqua-pale"> s</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife || ''}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadionuclideHalfLife: e.target.value,
},
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Injection Time')}
labelChildren={<span className="text-aqua-pale"> s</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={
metadata.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime || ''
}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadiopharmaceuticalStartTime: e.target.value,
},
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Acquisition Time')}
labelChildren={<span className="text-aqua-pale"> s</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.SeriesTime || ''}
onChange={() => {}}
/>
<Button
className="!h-[26px] !w-[115px] self-end !p-0"
onClick={updateMetadata}
>
Reload Data
</Button>
<>
{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>
</PanelSection>
<Separator
orientation="horizontal"
className="bg-black"
thickness="2px"
/>
</>
)}
<div className="ohif-scrollbar flex min-h-0 flex-auto select-none flex-col justify-between overflow-auto">
<div className="flex min-h-0 flex-1 flex-col bg-black text-[13px] font-[300]">
<PanelSection title={t('Patient Information')}>
<div className="flex flex-col">
<div className="bg-primary-dark flex flex-col gap-4 p-2">
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Patient Sex')}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.PatientSex || ''}
onChange={e => {
handleMetadataChange({
PatientSex: e.target.value,
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Weight')}
labelChildren={<span className="text-aqua-pale"> kg</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.PatientWeight || ''}
onChange={e => {
handleMetadataChange({
PatientWeight: e.target.value,
});
}}
id="weight-input"
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Total Dose')}
labelChildren={<span className="text-aqua-pale"> bq</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={
metadata.RadiopharmaceuticalInformationSequence.RadionuclideTotalDose || ''
}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadionuclideTotalDose: e.target.value,
},
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Half Life')}
labelChildren={<span className="text-aqua-pale"> s</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.RadiopharmaceuticalInformationSequence.RadionuclideHalfLife || ''}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadionuclideHalfLife: e.target.value,
},
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Injection Time')}
labelChildren={<span className="text-aqua-pale"> s</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={
metadata.RadiopharmaceuticalInformationSequence.RadiopharmaceuticalStartTime ||
''
}
onChange={e => {
handleMetadataChange({
RadiopharmaceuticalInformationSequence: {
RadiopharmaceuticalStartTime: e.target.value,
},
});
}}
/>
<Input
containerClassName={'!flex-row !justify-between items-center'}
label={t('Acquisition Time')}
labelChildren={<span className="text-aqua-pale"> s</span>}
labelClassName="text-[13px] font-inter text-white"
className="!m-0 !h-[26px] !w-[117px]"
value={metadata.SeriesTime || ''}
onChange={() => {}}
/>
<Button
className="!h-[26px] !w-[115px] self-end !p-0"
onClick={updateMetadata}
>
Reload Data
</Button>
</div>
</div>
</PanelSection>
</div>
</div>
</div>
</>
);
}

View File

@ -1,6 +1,8 @@
import React from 'react';
import { PanelPetSUV, PanelROIThresholdExport } from './Panels';
import { Toolbox } from '@ohif/ui';
import { Toolbox as NewToolbox } from '@ohif/ui-next';
import { Toolbox as OldToolbox } from '@ohif/ui';
import { useAppConfig } from '@state';
// TODO:
// - No loading UI exists yet
@ -8,16 +10,24 @@ import { Toolbox } from '@ohif/ui';
// - show errors in UI for thumbnails if promise fails
function getPanelModule({ commandsManager, extensionManager, servicesManager }) {
const wrappedPanelPetSuv = () => {
const wrappedPanelPetSuv = ({ renderHeader, getCloseIcon, tab }) => {
return (
<PanelPetSUV
commandsManager={commandsManager}
servicesManager={servicesManager}
extensionManager={extensionManager}
renderHeader={renderHeader}
getCloseIcon={getCloseIcon}
tab={tab}
/>
);
};
const wrappedROIThresholdToolbox = () => {
const wrappedROIThresholdToolbox = ({ renderHeader, getCloseIcon, tab }: withAppTypes) => {
const [appConfig] = useAppConfig();
const Toolbox = appConfig.useExperimentalUI ? NewToolbox : OldToolbox;
return (
<>
<Toolbox
@ -26,6 +36,9 @@ function getPanelModule({ commandsManager, extensionManager, servicesManager })
extensionManager={extensionManager}
buttonSectionId="ROIThresholdToolbox"
title="Threshold Tools"
renderHeader={renderHeader}
getCloseIcon={getCloseIcon}
tab={tab}
/>
</>
);

View File

@ -7,7 +7,7 @@ const dynamicVolume = {
};
const cornerstone = {
segmentation: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentation',
segmentation: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentationNoHeader',
activeViewportWindowLevel: '@ohif/extension-cornerstone.panelModule.activeViewportWindowLevel',
};

View File

@ -17,7 +17,7 @@ const ohif = {
const cs3d = {
viewport: '@ohif/extension-cornerstone.viewportModule.cornerstone',
segPanel: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentation',
segPanel: '@ohif/extension-cornerstone-dicom-seg.panelModule.panelSegmentationNoHeader',
};
const tmtv = {

View File

@ -16,6 +16,7 @@ window.config = {
experimentalStudyBrowserSort: false,
strictZSpacingForVolumeViewport: true,
groupEnabledModesFirst: true,
useExperimentalUI: false,
maxNumRequests: {
interaction: 100,
thumbnail: 75,

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,

View File

@ -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}
/>
))}
</>
);

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",

View File

@ -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 };

View File

@ -0,0 +1,3 @@
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from './Accordion';
export { Accordion, AccordionContent, AccordionItem, AccordionTrigger };

View File

@ -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;

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,3 @@
import { Icons } from './Icons';
export { Icons };

View File

@ -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;

View File

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

View File

@ -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 }

View File

@ -0,0 +1,3 @@
import { Separator } from "./Separator";
export default Separator;

View File

@ -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;

View File

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

View File

@ -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;

View File

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

View File

@ -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>
);
}

View File

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

View File

@ -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>
);
}

View File

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

View File

@ -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;

View File

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

View File

@ -0,0 +1,3 @@
import { Tabs, TabsList, TabsTrigger, TabsContent } from "./Tabs"
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -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;

View File

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

View File

@ -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;

View File

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

View File

@ -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',
},

View File

@ -0,0 +1,3 @@
import { Toggle, toggleVariants } from "./Toggle";
export { Toggle, toggleVariants };

View File

@ -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 };

View File

@ -0,0 +1,3 @@
import { ToggleGroup, ToggleGroupItem } from "./ToggleGroup";
export { ToggleGroup, ToggleGroupItem };

View File

@ -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;

View File

@ -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 };

View File

@ -0,0 +1,3 @@
import { ToolboxUI } from './ToolboxUI';
import Toolbox from './Toolbox';
export { ToolboxUI, Toolbox };

View File

@ -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}

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,
};

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,
};

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')],

View File

@ -3,7 +3,9 @@
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@ui/*": ["./src/*"]
"@ui/*": ["./src/*"],
"@ohif/core": ["../core/src"],
"@ohif/ui": ["../ui/src"]
}
},
"include": ["src"],

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',

View File

@ -3527,9 +3527,9 @@
graceful-fs "4.2.10"
"@pnpm/npm-conf@^2.1.0":
version "2.2.2"
resolved "https://registry.yarnpkg.com/@pnpm/npm-conf/-/npm-conf-2.2.2.tgz#0058baf1c26cbb63a828f0193795401684ac86f0"
integrity sha512-UA91GwWPhFExt3IizW6bOeY/pQ0BkuNwKjk9iQW9KqxluGCrg4VenZ0/L+2Y0+ZOtme72EVvg6v0zo3AMQRCeA==
version "2.3.1"
resolved "https://registry.yarnpkg.com/@pnpm/npm-conf/-/npm-conf-2.3.1.tgz#bb375a571a0bd63ab0a23bece33033c683e9b6b0"
integrity sha512-c83qWb22rNRuB0UaVCI0uRPNRr8Z0FWnEIvT47jiHAmOIUHbBOg5XvV7pM5x+rKn9HRpjxquDbXYSXr3fAKFcw==
dependencies:
"@pnpm/config.env-replace" "^1.1.0"
"@pnpm/network.ca-file" "^1.0.1"
@ -3564,6 +3564,21 @@
resolved "https://registry.yarnpkg.com/@radix-ui/primitive/-/primitive-1.1.0.tgz#42ef83b3b56dccad5d703ae8c42919a68798bbe2"
integrity sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA==
"@radix-ui/react-accordion@^1.2.0":
version "1.2.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-accordion/-/react-accordion-1.2.0.tgz#aed0770fcb16285db992d81873ccd7a014c7f17d"
integrity sha512-HJOzSX8dQqtsp/3jVxCU3CXEONF7/2jlGAB28oX8TTw1Dz8JYbEI1UcL8355PuLBE41/IRRMvCw7VkiK/jcUOQ==
dependencies:
"@radix-ui/primitive" "1.1.0"
"@radix-ui/react-collapsible" "1.1.0"
"@radix-ui/react-collection" "1.1.0"
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-context" "1.1.0"
"@radix-ui/react-direction" "1.1.0"
"@radix-ui/react-id" "1.1.0"
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-use-controllable-state" "1.1.0"
"@radix-ui/react-arrow@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-arrow/-/react-arrow-1.0.3.tgz#c24f7968996ed934d57fe6cde5d6ec7266e1d25d"
@ -3593,6 +3608,20 @@
"@radix-ui/react-use-previous" "1.1.0"
"@radix-ui/react-use-size" "1.1.0"
"@radix-ui/react-collapsible@1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collapsible/-/react-collapsible-1.1.0.tgz#4d49ddcc7b7d38f6c82f1fd29674f6fab5353e77"
integrity sha512-zQY7Epa8sTL0mq4ajSJpjgn2YmCgyrG7RsQgLp3C0LQVkG7+Tf6Pv1CeNWZLyqMjhdPkBa5Lx7wYBeSu7uCSTA==
dependencies:
"@radix-ui/primitive" "1.1.0"
"@radix-ui/react-compose-refs" "1.1.0"
"@radix-ui/react-context" "1.1.0"
"@radix-ui/react-id" "1.1.0"
"@radix-ui/react-presence" "1.1.0"
"@radix-ui/react-primitive" "2.0.0"
"@radix-ui/react-use-controllable-state" "1.1.0"
"@radix-ui/react-use-layout-effect" "1.1.0"
"@radix-ui/react-collection@1.0.3":
version "1.0.3"
resolved "https://registry.yarnpkg.com/@radix-ui/react-collection/-/react-collection-1.0.3.tgz#9595a66e09026187524a36c6e7e9c7d286469159"