feat(customization): new customization service api (#4688)

This commit is contained in:
Alireza 2025-01-23 14:24:13 -05:00 committed by GitHub
parent fab52808cb
commit 55ad8efbab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
124 changed files with 4261 additions and 2517 deletions

View File

@ -18,7 +18,8 @@
}, },
"rules": { "rules": {
// Enforce consistent brace style for all control statements for readability // Enforce consistent brace style for all control statements for readability
"curly": "error" "curly": "error",
"import/no-anonymous-default-export": "off"
}, },
"globals": { "globals": {
"cy": true, "cy": true,

BIN
bun.lockb

Binary file not shown.

View File

@ -1,5 +1,5 @@
function createRTToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) { function createRTToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) {
const { tools } = customizationService.get('cornerstone.overlayViewportTools') ?? {}; const tools = customizationService.getCustomization('cornerstone.overlayViewportTools');
return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools); return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools);
} }

View File

@ -1,5 +1,5 @@
function createSEGToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) { function createSEGToolGroupAndAddTools(ToolGroupService, customizationService, toolGroupId) {
const { tools } = customizationService.get('cornerstone.overlayViewportTools') ?? {}; const tools = customizationService.getCustomization('cornerstone.overlayViewportTools');
return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools); return ToolGroupService.createToolGroupAndAddTools(toolGroupId, tools);
} }

View File

@ -118,12 +118,11 @@ const commandsModule = (props: withAppTypes) => {
throw new Error('Invalid report, no content'); throw new Error('Invalid report, no content');
} }
const onBeforeDicomStore = const onBeforeDicomStore = customizationService.getCustomization('onBeforeDicomStore');
customizationService.getModeCustomization('onBeforeDicomStore')?.value;
let dicomDict; let dicomDict;
if (typeof onBeforeDicomStore === 'function') { if (typeof onBeforeDicomStore === 'function') {
dicomDict = onBeforeDicomStore({ measurementData, naturalizedReport }); dicomDict = onBeforeDicomStore({ dicomDict, measurementData, naturalizedReport });
} }
await dataSource.store.dicom(naturalizedReport, null, dicomDict); await dataSource.store.dicom(naturalizedReport, null, dicomDict);
@ -151,7 +150,7 @@ const commandsModule = (props: withAppTypes) => {
*/ */
loadSRMeasurements: ({ displaySetInstanceUID }) => { loadSRMeasurements: ({ displaySetInstanceUID }) => {
const { SeriesInstanceUIDs } = hydrateStructuredReport( const { SeriesInstanceUIDs } = hydrateStructuredReport(
{ servicesManager, extensionManager }, { servicesManager, extensionManager, commandsManager },
displaySetInstanceUID displaySetInstanceUID
); );

View File

@ -263,9 +263,9 @@ function _checkIfCanAddMeasurementsToDisplaySet(
for (let j = unloadedMeasurements.length - 1; j >= 0; j--) { for (let j = unloadedMeasurements.length - 1; j >= 0; j--) {
let measurement = unloadedMeasurements[j]; let measurement = unloadedMeasurements[j];
const onBeforeSRAddMeasurement = customizationService.getModeCustomization( const onBeforeSRAddMeasurement = customizationService.getCustomization(
'onBeforeSRAddMeasurement' 'onBeforeSRAddMeasurement'
)?.value; );
if (typeof onBeforeSRAddMeasurement === 'function') { if (typeof onBeforeSRAddMeasurement === 'function') {
measurement = onBeforeSRAddMeasurement({ measurement = onBeforeSRAddMeasurement({

View File

@ -41,22 +41,14 @@ const convertSites = (codingValues, sites) => {
* *
*/ */
export default function hydrateStructuredReport( export default function hydrateStructuredReport(
{ servicesManager, extensionManager }: withAppTypes, { servicesManager, extensionManager, commandsManager }: withAppTypes,
displaySetInstanceUID displaySetInstanceUID
) { ) {
const annotationManager = CsAnnotation.state.getAnnotationManager();
const dataSource = extensionManager.getActiveDataSource()[0]; const dataSource = extensionManager.getActiveDataSource()[0];
const { measurementService, displaySetService, customizationService } = servicesManager.services; const { measurementService, displaySetService, customizationService } = servicesManager.services;
const codingValues = customizationService.getCustomization('codingValues', {}); const codingValues = customizationService.getCustomization('codingValues');
const disableEditing = customizationService.getCustomization('panelMeasurement.disableEditing');
const { disableEditing } = customizationService.getCustomization(
'PanelMeasurement.disableEditing',
{
id: 'default.disableEditing',
disableEditing: false,
}
);
const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); const displaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
@ -107,8 +99,7 @@ export default function hydrateStructuredReport(
metaData metaData
); );
const onBeforeSRHydration = const onBeforeSRHydration = customizationService.getCustomization('onBeforeSRHydration')?.value;
customizationService.getModeCustomization('onBeforeSRHydration')?.value;
if (typeof onBeforeSRHydration === 'function') { if (typeof onBeforeSRHydration === 'function') {
storedMeasurementByAnnotationType = onBeforeSRHydration({ storedMeasurementByAnnotationType = onBeforeSRHydration({
@ -230,8 +221,7 @@ export default function hydrateStructuredReport(
}); });
if (disableEditing) { if (disableEditing) {
const addedAnnotation = annotationManager.getAnnotation(newAnnotationUID); locking.setAnnotationLocked(newAnnotationUID, true);
locking.setAnnotationLocked(addedAnnotation, true);
} }
if (!imageIds.includes(imageId)) { if (!imageIds.includes(imageId)) {

View File

@ -5,7 +5,7 @@ function WorkflowPanel({ servicesManager }: { servicesManager: ServicesManager }
const ProgressDropdownWithService = const ProgressDropdownWithService =
servicesManager.services.customizationService.getCustomization( servicesManager.services.customizationService.getCustomization(
'progressDropdownWithServiceComponent' 'progressDropdownWithServiceComponent'
).component; );
return ( return (
<div <div

View File

@ -47,80 +47,6 @@ const OverlayItemComponents = {
'ohif.overlayItem.instanceNumber': InstanceNumberOverlayItem, 'ohif.overlayItem.instanceNumber': InstanceNumberOverlayItem,
}; };
const studyDateItem = {
id: 'StudyDate',
customizationType: 'ohif.overlayItem',
label: '',
title: 'Study date',
condition: ({ referenceInstance }) => referenceInstance?.StudyDate,
contentF: ({ referenceInstance, formatters: { formatDate } }) =>
formatDate(referenceInstance.StudyDate),
};
const seriesDescriptionItem = {
id: 'SeriesDescription',
customizationType: 'ohif.overlayItem',
label: '',
title: 'Series description',
condition: ({ referenceInstance }) => {
return referenceInstance && referenceInstance.SeriesDescription;
},
contentF: ({ referenceInstance }) => referenceInstance.SeriesDescription,
};
const topLeftItems = {
id: 'cornerstoneOverlayTopLeft',
items: [studyDateItem, seriesDescriptionItem],
};
const topRightItems = { id: 'cornerstoneOverlayTopRight', items: [] };
const bottomLeftItems = {
id: 'cornerstoneOverlayBottomLeft',
items: [
{
id: 'WindowLevel',
customizationType: 'ohif.overlayItem.windowLevel',
},
{
id: 'ZoomLevel',
customizationType: 'ohif.overlayItem.zoomLevel',
condition: props => {
const activeToolName = props.toolGroupService.getActiveToolForViewport(props.viewportId);
return activeToolName === 'Zoom';
},
},
],
};
const bottomRightItems = {
id: 'cornerstoneOverlayBottomRight',
items: [
{
id: 'InstanceNumber',
customizationType: 'ohif.overlayItem.instanceNumber',
},
],
};
/**
* The @ohif/cornerstoneOverlay is a default value for a customization
* for the cornerstone overlays. The intent is to allow it to be extended
* without needing to re-write the individual overlays by using the append
* mechanism. Individual attributes can be modified individually without
* affecting the other items by using the append as well, with position
* based replacement.
* This is used as a default in the getCustomizationModule so that it
* is available early for additional customization extensions.
*/
const CornerstoneOverlay = {
id: '@ohif/cornerstoneOverlay',
topLeftItems,
topRightItems,
bottomLeftItems,
bottomRightItems,
};
/** /**
* Customizable Viewport Overlay * Customizable Viewport Overlay
*/ */
@ -143,26 +69,18 @@ function CustomizableViewportOverlay({
const [scale, setScale] = useState(1); const [scale, setScale] = useState(1);
const { imageIndex } = imageSliceData; const { imageIndex } = imageSliceData;
// The new customization is 'cornerstoneOverlay', with an append or replace
// on the individual items rather than defining individual items.
const cornerstoneOverlay = customizationService.getCustomization('@ohif/cornerstoneOverlay');
// Historical usage defined the overlays as separate items due to lack of // Historical usage defined the overlays as separate items due to lack of
// append functionality. This code enables the historical usage, but // append functionality. This code enables the historical usage, but
// the recommended functionality is to append to the default values in // the recommended functionality is to append to the default values in
// cornerstoneOverlay rather than defining individual items. // cornerstoneOverlay rather than defining individual items.
const topLeftCustomization = const topLeftCustomization = customizationService.getCustomization('viewportOverlay.topLeft');
customizationService.getCustomization('cornerstoneOverlayTopLeft') || const topRightCustomization = customizationService.getCustomization('viewportOverlay.topRight');
cornerstoneOverlay?.topLeftItems; const bottomLeftCustomization = customizationService.getCustomization(
const topRightCustomization = 'viewportOverlay.bottomLeft'
customizationService.getCustomization('cornerstoneOverlayTopRight') || );
cornerstoneOverlay?.topRightItems; const bottomRightCustomization = customizationService.getCustomization(
const bottomLeftCustomization = 'viewportOverlay.bottomRight'
customizationService.getCustomization('cornerstoneOverlayBottomLeft') || );
cornerstoneOverlay?.bottomLeftItems;
const bottomRightCustomization =
customizationService.getCustomization('cornerstoneOverlayBottomRight') ||
cornerstoneOverlay?.bottomRightItems;
const instanceNumber = useMemo( const instanceNumber = useMemo(
() => () =>
@ -264,8 +182,8 @@ function CustomizableViewportOverlay({
return null; return null;
} }
const { customizationType } = item; const { inheritsFrom } = item;
const OverlayItemComponent = OverlayItemComponents[customizationType]; const OverlayItemComponent = OverlayItemComponents[inheritsFrom];
if (OverlayItemComponent) { if (OverlayItemComponent) {
return <OverlayItemComponent {...overlayItemProps} />; return <OverlayItemComponent {...overlayItemProps} />;
@ -293,10 +211,6 @@ function CustomizableViewportOverlay({
const getContent = useCallback( const getContent = useCallback(
(customization, keyPrefix) => { (customization, keyPrefix) => {
if (!customization?.items) {
return null;
}
const { items } = customization;
const props = { const props = {
...displaySetProps, ...displaySetProps,
formatters: { formatDate: formatDICOMDate }, formatters: { formatDate: formatDICOMDate },
@ -309,7 +223,7 @@ function CustomizableViewportOverlay({
return ( return (
<> <>
{items.map((item, index) => ( {customization.map((item, index) => (
<div key={`${keyPrefix}_${index}`}> <div key={`${keyPrefix}_${index}`}>
{((!item?.condition || item.condition(props)) && _renderOverlayItem(item, props)) || {((!item?.condition || item.condition(props)) && _renderOverlayItem(item, props)) ||
null} null}
@ -533,4 +447,4 @@ CustomizableViewportOverlay.propTypes = {
export default CustomizableViewportOverlay; export default CustomizableViewportOverlay;
export { CustomizableViewportOverlay, CornerstoneOverlay }; export { CustomizableViewportOverlay };

View File

@ -194,7 +194,7 @@ function commandsModule({
* on the measurement with a response if not cancelled. * on the measurement with a response if not cancelled.
*/ */
setMeasurementLabel: ({ uid }) => { setMeasurementLabel: ({ uid }) => {
const labelConfig = customizationService.get('measurementLabels'); const labelConfig = customizationService.getCustomization('measurementLabels');
const measurement = measurementService.getMeasurement(uid); const measurement = measurementService.getMeasurement(uid);
showLabelAnnotationPopup(measurement, uiDialogService, labelConfig).then( showLabelAnnotationPopup(measurement, uiDialogService, labelConfig).then(
(val: Map<any, any>) => { (val: Map<any, any>) => {
@ -311,7 +311,7 @@ function commandsModule({
}, },
renameMeasurement: ({ uid }) => { renameMeasurement: ({ uid }) => {
const labelConfig = customizationService.get('measurementLabels'); const labelConfig = customizationService.getCustomization('measurementLabels');
const measurement = measurementService.getMeasurement(uid); const measurement = measurementService.getMeasurement(uid);
showLabelAnnotationPopup(measurement, uiDialogService, labelConfig).then(val => { showLabelAnnotationPopup(measurement, uiDialogService, labelConfig).then(val => {
measurementService.update( measurementService.update(
@ -362,7 +362,7 @@ function commandsModule({
viewportGridService.setActiveViewportId(viewportId); viewportGridService.setActiveViewportId(viewportId);
}, },
arrowTextCallback: ({ callback, data, uid }) => { arrowTextCallback: ({ callback, data, uid }) => {
const labelConfig = customizationService.get('measurementLabels'); const labelConfig = customizationService.getCustomization('measurementLabels');
callLabelAutocompleteDialog(uiDialogService, callback, {}, labelConfig); callLabelAutocompleteDialog(uiDialogService, callback, {}, labelConfig);
}, },
toggleCine: () => { toggleCine: () => {

View File

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState, useRef } from 'react'; import React, { useCallback, useEffect, useState, useRef } from 'react';
import { CinePlayer, useCine } from '@ohif/ui'; import { useCine } from '@ohif/ui';
import { Enums, eventTarget, cache } from '@cornerstonejs/core'; import { Enums, eventTarget, cache } from '@cornerstonejs/core';
import { useAppConfig } from '@state'; import { useAppConfig } from '@state';
@ -160,8 +160,7 @@ function RenderCinePlayer({
dynamicInfo: dynamicInfoProp, dynamicInfo: dynamicInfoProp,
customizationService, customizationService,
}) { }) {
const { component: CinePlayerComponent = CinePlayer } = const CinePlayerComponent = customizationService.getCustomization('cinePlayer');
customizationService.get('cinePlayer') ?? {};
const [dynamicInfo, setDynamicInfo] = useState(dynamicInfoProp); const [dynamicInfo, setDynamicInfo] = useState(dynamicInfoProp);

View File

@ -2,21 +2,21 @@
// configured via the customization service. // configured via the customization service.
const defaultWindowLevelPresets = { const defaultWindowLevelPresets = {
CT: [ CT: [
{ description: 'Soft tissue', window: '400', level: '40' }, { id: 'ct-soft-tissue', description: 'Soft tissue', window: '400', level: '40' },
{ description: 'Lung', window: '1500', level: '-600' }, { id: 'ct-lung', description: 'Lung', window: '1500', level: '-600' },
{ description: 'Liver', window: '150', level: '90' }, { id: 'ct-liver', description: 'Liver', window: '150', level: '90' },
{ description: 'Bone', window: '2500', level: '480' }, { id: 'ct-bone', description: 'Bone', window: '2500', level: '480' },
{ description: 'Brain', window: '80', level: '40' }, { id: 'ct-brain', description: 'Brain', window: '80', level: '40' },
], ],
PT: [ PT: [
{ description: 'Default', window: '5', level: '2.5' }, { id: 'pt-default', description: 'Default', window: '5', level: '2.5' },
{ description: 'SUV', window: '0', level: '3' }, { id: 'pt-suv-3', description: 'SUV', window: '0', level: '3' },
{ description: 'SUV', window: '0', level: '5' }, { id: 'pt-suv-5', description: 'SUV', window: '0', level: '5' },
{ description: 'SUV', window: '0', level: '7' }, { id: 'pt-suv-7', description: 'SUV', window: '0', level: '7' },
{ description: 'SUV', window: '0', level: '8' }, { id: 'pt-suv-8', description: 'SUV', window: '0', level: '8' },
{ description: 'SUV', window: '0', level: '10' }, { id: 'pt-suv-10', description: 'SUV', window: '0', level: '10' },
{ description: 'SUV', window: '0', level: '15' }, { id: 'pt-suv-15', description: 'SUV', window: '0', level: '15' },
], ],
}; };

View File

@ -16,11 +16,10 @@ export function getWindowLevelActionMenu({
}>): ReactNode { }>): ReactNode {
const { customizationService } = servicesManager.services; const { customizationService } = servicesManager.services;
const { presets } = customizationService.get('cornerstone.windowLevelPresets'); const presets = customizationService.getCustomization('cornerstone.windowLevelPresets');
const colorbarProperties = customizationService.get('cornerstone.colorbar'); const colorbarProperties = customizationService.getCustomization('cornerstone.colorbar');
const { volumeRenderingPresets, volumeRenderingQualityRange } = customizationService.get( const { volumeRenderingPresets, volumeRenderingQualityRange } =
'cornerstone.3dVolumeRendering' customizationService.getCustomization('cornerstone.3dVolumeRendering');
);
const displaySetPresets = displaySets const displaySetPresets = displaySets
.filter(displaySet => presets[displaySet.Modality]) .filter(displaySet => presets[displaySet.Modality])

View File

@ -0,0 +1,13 @@
import { colormaps } from '../utils/colormaps';
const DefaultColormap = 'Grayscale';
export default {
'cornerstone.colorbar': {
width: '16px',
colorbarTickPosition: 'left',
colormaps,
colorbarContainerPosition: 'right',
colorbarInitialColormap: DefaultColormap,
},
};

View File

@ -0,0 +1,99 @@
export default {
'layoutSelector.advancedPresetGenerator': ({
servicesManager,
}: {
servicesManager: AppTypes.ServicesManager;
}) => {
const _areSelectorsValid = (
hp: AppTypes.HangingProtocol.Protocol,
displaySets: AppTypes.DisplaySet[],
hangingProtocolService: AppTypes.HangingProtocolService
) => {
if (!hp.displaySetSelectors || Object.values(hp.displaySetSelectors).length === 0) {
return true;
}
return hangingProtocolService.areRequiredSelectorsValid(
Object.values(hp.displaySetSelectors),
displaySets[0]
);
};
const generateAdvancedPresets = ({
servicesManager,
}: {
servicesManager: AppTypes.ServicesManager;
}) => {
const { hangingProtocolService, viewportGridService, displaySetService } =
servicesManager.services;
const hangingProtocols = Array.from(hangingProtocolService.protocols.values());
const viewportId = viewportGridService.getActiveViewportId();
if (!viewportId) {
return [];
}
const displaySetInsaneUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
if (!displaySetInsaneUIDs) {
return [];
}
const displaySets = displaySetInsaneUIDs.map(uid =>
displaySetService.getDisplaySetByUID(uid)
);
return hangingProtocols
.map(hp => {
if (!hp.isPreset) {
return null;
}
const areValid = _areSelectorsValid(hp, displaySets, hangingProtocolService);
return {
icon: hp.icon,
title: hp.name,
commandOptions: {
protocolId: hp.id,
},
disabled: !areValid,
};
})
.filter(preset => preset !== null);
};
return generateAdvancedPresets({ servicesManager });
},
'layoutSelector.commonPresets': [
{
icon: 'layout-common-1x1',
commandOptions: {
numRows: 1,
numCols: 1,
},
},
{
icon: 'layout-common-1x2',
commandOptions: {
numRows: 1,
numCols: 2,
},
},
{
icon: 'layout-common-2x2',
commandOptions: {
numRows: 2,
numCols: 2,
},
},
{
icon: 'layout-common-2x3',
commandOptions: {
numRows: 2,
numCols: 3,
},
},
],
};

View File

@ -0,0 +1,124 @@
export default {
'cornerstone.measurements': {
Angle: {
displayText: [],
report: [],
},
CobbAngle: {
displayText: [],
report: [],
},
ArrowAnnotate: {
displayText: [],
report: [],
},
RectangleROi: {
displayText: [],
report: [],
},
CircleROI: {
displayText: [],
report: [],
},
EllipticalROI: {
displayText: [],
report: [],
},
Bidirectional: {
displayText: [],
report: [],
},
Length: {
displayText: [],
report: [],
},
LivewireContour: {
displayText: [],
report: [],
},
SplineROI: {
displayText: [
{
displayName: 'Areas',
value: 'area',
type: 'value',
},
{
value: 'areaUnits',
for: ['area'],
type: 'unit',
},
],
report: [
{
displayName: 'Area',
value: 'area',
type: 'value',
},
{
displayName: 'Unit',
value: 'areaUnits',
type: 'value',
},
],
},
PlanarFreehandROI: {
displayTextOpen: [
{
displayName: 'Length',
value: 'length',
type: 'value',
},
],
displayText: [
{
displayName: 'Mean',
value: 'mean',
type: 'value',
},
{
displayName: 'Max',
value: 'max',
type: 'value',
},
{
displayName: 'Area',
value: 'area',
type: 'value',
},
{
value: 'pixelValueUnits',
for: ['mean', 'max'],
type: 'unit',
},
{
value: 'areaUnits',
for: ['area'],
type: 'unit',
},
],
report: [
{
displayName: 'Mean',
value: 'mean',
type: 'value',
},
{
displayName: 'Max',
value: 'max',
type: 'value',
},
{
displayName: 'Area',
value: 'area',
type: 'value',
},
{
displayName: 'Unit',
value: 'unit',
type: 'value',
},
],
},
},
};

View File

@ -0,0 +1,16 @@
import { CinePlayer } from '@ohif/ui';
import DicomUpload from '../components/DicomUpload/DicomUpload';
export default {
cinePlayer: CinePlayer,
autoCineModalities: ['OT', 'US'],
'panelMeasurement.disableEditing': false,
onBeforeSRAddMeasurement: ({ measurement, StudyInstanceUID, SeriesInstanceUID }) => {
return measurement;
},
onBeforeDicomStore: ({ dicomDict, measurementData, naturalizedReport }) => {
return dicomDict;
},
dicomUploadComponent: DicomUpload,
codingValues: {},
};

View File

@ -0,0 +1,95 @@
import React from 'react';
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
Icons,
} from '@ohif/ui-next';
export default function getSegmentationPanelCustomization({ commandsManager, servicesManager }) {
return {
'panelSegmentation.customDropdownMenuContent': ({
activeSegmentation,
onSegmentationAdd,
onSegmentationRemoveFromViewport,
onSegmentationEdit,
onSegmentationDelete,
allowExport,
storeSegmentation,
onSegmentationDownload,
onSegmentationDownloadRTSS,
t,
}) => (
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onSegmentationAdd(activeSegmentation.segmentationId)}>
<Icons.Add className="text-foreground" />
<span className="pl-2">{t('Create New Segmentation')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('Manage Current Segmentation')}</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => onSegmentationRemoveFromViewport(activeSegmentation.segmentationId)}
>
<Icons.Series className="text-foreground" />
<span className="pl-2">{t('Remove from Viewport')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSegmentationEdit(activeSegmentation.segmentationId)}>
<Icons.Rename className="text-foreground" />
<span className="pl-2">{t('Rename')}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={!allowExport}
className="pl-1"
>
<Icons.Export className="text-foreground" />
<span className="pl-2">{t('Export & Download')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() => storeSegmentation(activeSegmentation.segmentationId)}
>
{t('Export DICOM SEG')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSegmentationDownload(activeSegmentation.segmentationId)}
>
{t('Download DICOM SEG')}
</DropdownMenuItem>
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onSegmentationDelete(activeSegmentation.segmentationId)}>
<Icons.Delete className="text-red-600" />
<span className="pl-2 text-red-600">{t('Delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
),
'panelSegmentation.disableEditing': false,
'panelSegmentation.showAddSegment': true,
'panelSegmentation.onSegmentationAdd': () => {
const { viewportGridService } = servicesManager.services;
const viewportId = viewportGridService.getState().activeViewportId;
commandsManager.run('createLabelmapForViewport', { viewportId });
},
'panelSegmentation.tableMode': 'collapsed',
'panelSegmentation.readableText': {
lesionStats: 'Lesion Statistics',
minValue: 'Minimum Value',
maxValue: 'Maximum Value',
meanValue: 'Mean Value',
volume: 'Volume (ml)',
suvPeak: 'SUV Peak',
suvMax: 'Maximum SUV',
suvMaxIJK: 'SUV Max IJK',
lesionGlyoclysisStats: 'Lesion Glycolysis',
},
};
}

View File

@ -0,0 +1,15 @@
export default {
cornerstoneViewportClickCommands: {
doubleClick: ['toggleOneUp'],
button1: ['closeContextMenu'],
button3: [
{
commandName: 'showCornerstoneContextMenu',
commandOptions: {
requireNearbyToolData: true,
menuId: 'measurementsContextMenu',
},
},
],
},
};

View File

@ -0,0 +1,44 @@
export default {
'viewportOverlay.topLeft': [
{
id: 'StudyDate',
inheritsFrom: 'ohif.overlayItem',
label: '',
title: 'Study date',
condition: ({ referenceInstance }) => referenceInstance?.StudyDate,
contentF: ({ referenceInstance, formatters: { formatDate } }) =>
formatDate(referenceInstance.StudyDate),
},
{
id: 'SeriesDescription',
inheritsFrom: 'ohif.overlayItem',
label: '',
title: 'Series description',
condition: ({ referenceInstance }) => {
return referenceInstance && referenceInstance.SeriesDescription;
},
contentF: ({ referenceInstance }) => referenceInstance.SeriesDescription,
},
],
'viewportOverlay.topRight': [],
'viewportOverlay.bottomLeft': [
{
id: 'WindowLevel',
inheritsFrom: 'ohif.overlayItem.windowLevel',
},
{
id: 'ZoomLevel',
inheritsFrom: 'ohif.overlayItem.zoomLevel',
condition: props => {
const activeToolName = props.toolGroupService.getActiveToolForViewport(props.viewportId);
return activeToolName === 'Zoom';
},
},
],
'viewportOverlay.bottomRight': [
{
id: 'InstanceNumber',
inheritsFrom: 'ohif.overlayItem.instanceNumber',
},
],
};

View File

@ -0,0 +1,33 @@
import { Enums } from '@cornerstonejs/tools';
import { toolNames } from '../initCornerstoneTools';
export default {
'cornerstone.overlayViewportTools': {
active: [
{
toolName: toolNames.WindowLevel,
bindings: [{ mouseButton: Enums.MouseBindings.Primary }],
},
{
toolName: toolNames.Pan,
bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }],
},
{
toolName: toolNames.Zoom,
bindings: [{ mouseButton: Enums.MouseBindings.Secondary }],
},
{
toolName: toolNames.StackScroll,
bindings: [{ mouseButton: Enums.MouseBindings.Wheel }],
},
],
enabled: [
{
toolName: toolNames.PlanarFreehandContourSegmentation,
configuration: {
displayOnePointAsCrosshairs: true,
},
},
],
},
};

View File

@ -0,0 +1,14 @@
import { CONSTANTS } from '@cornerstonejs/core';
const { VIEWPORT_PRESETS } = CONSTANTS;
export default {
'cornerstone.3dVolumeRendering': {
volumeRenderingPresets: VIEWPORT_PRESETS,
volumeRenderingQualityRange: {
min: 1,
max: 4,
step: 1,
},
},
};

View File

@ -0,0 +1,5 @@
import defaultWindowLevelPresets from '../components/WindowLevelActionMenu/defaultWindowLevelPresets';
export default {
'cornerstone.windowLevelPresets': defaultWindowLevelPresets,
};

View File

@ -1,213 +0,0 @@
import { Enums } from '@cornerstonejs/tools';
import { toolNames } from './initCornerstoneTools';
import defaultWindowLevelPresets from './components/WindowLevelActionMenu/defaultWindowLevelPresets';
import { colormaps } from './utils/colormaps';
import { CONSTANTS } from '@cornerstonejs/core';
import { CornerstoneOverlay } from './Viewport/Overlays/CustomizableViewportOverlay';
const DefaultColormap = 'Grayscale';
const { VIEWPORT_PRESETS } = CONSTANTS;
const tools = {
active: [
{
toolName: toolNames.WindowLevel,
bindings: [{ mouseButton: Enums.MouseBindings.Primary }],
},
{
toolName: toolNames.Pan,
bindings: [{ mouseButton: Enums.MouseBindings.Auxiliary }],
},
{
toolName: toolNames.Zoom,
bindings: [{ mouseButton: Enums.MouseBindings.Secondary }],
},
{
toolName: toolNames.StackScroll,
bindings: [{ mouseButton: Enums.MouseBindings.Wheel }],
},
],
enabled: [
{
toolName: toolNames.PlanarFreehandContourSegmentation,
configuration: {
displayOnePointAsCrosshairs: true,
},
},
],
};
function getCustomizationModule() {
return [
{
name: 'default',
value: [
CornerstoneOverlay,
{
id: 'cornerstone.overlayViewportTools',
tools,
},
{
id: 'cornerstone.windowLevelPresets',
presets: defaultWindowLevelPresets,
},
{
id: 'cornerstone.colorbar',
width: '16px',
colorbarTickPosition: 'left',
colormaps,
colorbarContainerPosition: 'right',
colorbarInitialColormap: DefaultColormap,
},
{
id: 'cornerstone.3dVolumeRendering',
volumeRenderingPresets: VIEWPORT_PRESETS,
volumeRenderingQualityRange: {
min: 1,
max: 4,
step: 1,
},
},
{
id: 'cornerstone.measurements',
Angle: {
displayText: [],
report: [],
},
CobbAngle: {
displayText: [],
report: [],
},
ArrowAnnotate: {
displayText: [],
report: [],
},
RectangleROi: {
displayText: [],
report: [],
},
CircleROI: {
displayText: [],
report: [],
},
EllipticalROI: {
displayText: [],
report: [],
},
Bidirectional: {
displayText: [],
report: [],
},
Length: {
displayText: [],
report: [],
},
LivewireContour: {
displayText: [],
report: [],
},
SplineROI: {
displayText: [
{
displayName: 'Area',
value: 'area',
type: 'value',
},
{
value: 'areaUnits',
for: ['area'],
type: 'unit',
},
/**
{
displayName: 'Modality',
value: 'Modality',
type: 'value',
},
*/
],
report: [
{
displayName: 'Area',
value: 'area',
type: 'value',
},
{
displayName: 'Unit',
value: 'areaUnits',
type: 'value',
},
],
},
PlanarFreehandROI: {
displayTextOpen: [
{
displayName: 'Length',
value: 'length',
type: 'value',
},
],
displayText: [
{
displayName: 'Mean',
value: 'mean',
type: 'value',
},
{
displayName: 'Max',
value: 'max',
type: 'value',
},
{
displayName: 'Area',
value: 'area',
type: 'value',
},
{
value: 'pixelValueUnits',
for: ['mean', 'max' /** 'stdDev **/],
type: 'unit',
},
{
value: 'areaUnits',
for: ['area'],
type: 'unit',
},
/**
{
displayName: 'Std Dev',
value: 'stdDev',
type: 'value',
},
*/
],
report: [
{
displayName: 'Mean',
value: 'mean',
type: 'value',
},
{
displayName: 'Max',
value: 'max',
type: 'value',
},
{
displayName: 'Area',
value: 'area',
type: 'value',
},
{
displayName: 'Unit',
value: 'unit',
type: 'value',
},
],
},
},
],
},
];
}
export default getCustomizationModule;

View File

@ -0,0 +1,32 @@
import viewportOverlayCustomization from './customizations/viewportOverlayCustomization';
import getSegmentationPanelCustomization from './customizations/segmentationPanelCustomization';
import layoutSelectorCustomization from './customizations/layoutSelectorCustomization';
import viewportToolsCustomization from './customizations/viewportToolsCustomization';
import viewportClickCommandsCustomization from './customizations/viewportClickCommandsCustomization';
import measurementsCustomization from './customizations/measurementsCustomization';
import volumeRenderingCustomization from './customizations/volumeRenderingCustomization';
import colorbarCustomization from './customizations/colorbarCustomization';
import windowLevelPresetsCustomization from './customizations/windowLevelPresetsCustomization';
import miscCustomization from './customizations/miscCustomization';
function getCustomizationModule({ commandsManager, servicesManager }) {
return [
{
name: 'default',
value: {
...viewportOverlayCustomization,
...getSegmentationPanelCustomization({ commandsManager, servicesManager }),
...layoutSelectorCustomization,
...viewportToolsCustomization,
...viewportClickCommandsCustomization,
...measurementsCustomization,
...volumeRenderingCustomization,
...colorbarCustomization,
...windowLevelPresetsCustomization,
...miscCustomization,
},
},
];
}
export default getCustomizationModule;

View File

@ -12,10 +12,7 @@ function mapSegmentationToDisplay(segmentation, customizationService) {
const { label, segments } = segmentation; const { label, segments } = segmentation;
// Get the readable text mapping once // Get the readable text mapping once
const { readableText: readableTextMap } = customizationService.getCustomization( const readableTextMap = customizationService.getCustomization('panelSegmentation.readableText');
'PanelSegmentation.readableText',
{}
);
// Helper function to recursively map cachedStats to readable display text // Helper function to recursively map cachedStats to readable display text
function mapStatsToDisplay(stats, indent = 0) { function mapStatsToDisplay(stats, indent = 0) {

View File

@ -7,10 +7,7 @@ function mapSegmentationToDisplay(segmentation, customizationService) {
const { label, segments } = segmentation; const { label, segments } = segmentation;
// Get the readable text mapping once // Get the readable text mapping once
const { readableText: readableTextMap } = customizationService.getCustomization( const readableTextMap = customizationService.getCustomization('panelSegmentation.readableText');
'PanelSegmentation.readableText',
{}
);
// Helper function to recursively map cachedStats to readable display text // Helper function to recursively map cachedStats to readable display text
function mapStatsToDisplay(stats, indent = 0) { function mapStatsToDisplay(stats, indent = 0) {

View File

@ -160,11 +160,9 @@ const cornerstoneExtension: Types.Extensions.Extension = {
ViewportActionCornersProvider ViewportActionCornersProvider
); );
const { syncGroupService } = servicesManager.services; const { syncGroupService, customizationService } = servicesManager.services;
syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer); syncGroupService.registerCustomSynchronizer('frameview', createFrameViewSynchronizer);
servicesManager.services.customizationService.setGlobalCustomization('dicomUploadComponent', {
component: props => <DicomUpload {...props} />,
});
return init.call(this, props); return init.call(this, props);
}, },
getToolbarModule, getToolbarModule,

View File

@ -5,27 +5,6 @@ import { findNearbyToolData } from './utils/findNearbyToolData';
const cs3DToolsEvents = Enums.Events; const cs3DToolsEvents = Enums.Events;
const DEFAULT_CONTEXT_MENU_CLICKS = {
button1: {
commands: [
{
commandName: 'closeContextMenu',
},
],
},
button3: {
commands: [
{
commandName: 'showCornerstoneContextMenu',
commandOptions: {
requireNearbyToolData: true,
menuId: 'measurementsContextMenu',
},
},
],
},
};
/** /**
* Generates a name, consisting of: * Generates a name, consisting of:
* * alt when the alt key is down * * alt when the alt key is down
@ -60,8 +39,10 @@ function initContextMenu({
* defaults on button1 and button2 * defaults on button1 and button2
*/ */
const cornerstoneViewportHandleEvent = (name, evt) => { const cornerstoneViewportHandleEvent = (name, evt) => {
const customizations = const customizations = customizationService.getCustomization(
customizationService.get('cornerstoneViewportClickCommands') || DEFAULT_CONTEXT_MENU_CLICKS; 'cornerstoneViewportClickCommands'
);
const toRun = customizations[name]; const toRun = customizations[name];
if (!toRun) { if (!toRun) {
@ -71,7 +52,7 @@ function initContextMenu({
// only find nearbyToolData if required, for the click (which closes the context menu // only find nearbyToolData if required, for the click (which closes the context menu
// we don't need to find nearbyToolData) // we don't need to find nearbyToolData)
let nearbyToolData = null; let nearbyToolData = null;
if (toRun.commands.some(command => command.commandOptions?.requireNearbyToolData)) { if (toRun.some(command => command.commandOptions?.requireNearbyToolData)) {
nearbyToolData = findNearbyToolData(commandsManager, evt); nearbyToolData = findNearbyToolData(commandsManager, evt);
} }

View File

@ -1,17 +1,10 @@
import { eventTarget, EVENTS } from '@cornerstonejs/core'; import { eventTarget, EVENTS } from '@cornerstonejs/core';
import { Enums } from '@cornerstonejs/tools'; import { Enums } from '@cornerstonejs/tools';
import { CommandsManager, CustomizationService, Types } from '@ohif/core'; import { CommandsManager, CustomizationService } from '@ohif/core';
import { findNearbyToolData } from './utils/findNearbyToolData'; import { findNearbyToolData } from './utils/findNearbyToolData';
const cs3DToolsEvents = Enums.Events; const cs3DToolsEvents = Enums.Events;
const DEFAULT_DOUBLE_CLICK = {
doubleClick: {
commandName: 'toggleOneUp',
commandOptions: {},
},
};
/** /**
* Generates a double click event name, consisting of: * Generates a double click event name, consisting of:
* * alt when the alt key is down * * alt when the alt key is down
@ -50,8 +43,9 @@ function initDoubleClick({ customizationService, commandsManager }: initDoubleCl
const eventName = getDoubleClickEventName(evt); const eventName = getDoubleClickEventName(evt);
// Allows for the customization of the double click on a viewport. // Allows for the customization of the double click on a viewport.
const customizations = const customizations = customizationService.getCustomization(
customizationService.get('cornerstoneViewportClickCommands') || DEFAULT_DOUBLE_CLICK; 'cornerstoneViewportClickCommands'
);
const toRun = customizations[eventName]; const toRun = customizations[eventName];

View File

@ -1,4 +1,4 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef } from 'react';
import { utils } from '@ohif/core'; import { utils } from '@ohif/core';
import { MeasurementTable } from '@ohif/ui-next'; import { MeasurementTable } from '@ohif/ui-next';
import debounce from 'lodash.debounce'; import debounce from 'lodash.debounce';

View File

@ -1,16 +1,5 @@
import React from 'react'; import React from 'react';
import { import { SegmentationTable } from '@ohif/ui-next';
DropdownMenuLabel,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
Icons,
SegmentationTable,
DropdownMenuSubTrigger,
} from '@ohif/ui-next';
import { useActiveViewportSegmentationRepresentations } from '../hooks/useActiveViewportSegmentationRepresentations'; import { useActiveViewportSegmentationRepresentations } from '../hooks/useActiveViewportSegmentationRepresentations';
import { metaData } from '@cornerstonejs/core'; import { metaData } from '@cornerstonejs/core';
@ -19,7 +8,7 @@ export default function PanelSegmentation({
commandsManager, commandsManager,
children, children,
}: withAppTypes) { }: withAppTypes) {
const { customizationService, viewportGridService, displaySetService } = servicesManager.services; const { customizationService, displaySetService } = servicesManager.services;
const { segmentationsWithRepresentations, disabled } = const { segmentationsWithRepresentations, disabled } =
useActiveViewportSegmentationRepresentations({ useActiveViewportSegmentationRepresentations({
@ -27,11 +16,6 @@ export default function PanelSegmentation({
}); });
const handlers = { const handlers = {
onSegmentationAdd: async () => {
const viewportId = viewportGridService.getState().activeViewportId;
commandsManager.run('createLabelmapForViewport', { viewportId });
},
onSegmentationClick: (segmentationId: string) => { onSegmentationClick: (segmentationId: string) => {
commandsManager.run('setActiveSegmentation', { segmentationId }); commandsManager.run('setActiveSegmentation', { segmentationId });
}, },
@ -125,107 +109,19 @@ export default function PanelSegmentation({
}, },
}; };
const { mode: SegmentationTableMode } = customizationService.getCustomization( const segmentationTableMode = customizationService.getCustomization(
'PanelSegmentation.tableMode', 'panelSegmentation.tableMode'
{
id: 'default.segmentationTable.mode',
mode: 'collapsed',
}
); );
// custom onSegmentationAdd if provided // custom onSegmentationAdd if provided
const { onSegmentationAdd } = customizationService.getCustomization( const onSegmentationAdd = customizationService.getCustomization(
'PanelSegmentation.onSegmentationAdd', 'panelSegmentation.onSegmentationAdd'
{
id: 'segmentation.onSegmentationAdd',
onSegmentationAdd: handlers.onSegmentationAdd,
}
); );
const { disableEditing } = customizationService.getCustomization( const disableEditing = customizationService.getCustomization('panelSegmentation.disableEditing');
'PanelSegmentation.disableEditing', const showAddSegment = customizationService.getCustomization('panelSegmentation.showAddSegment');
{ const CustomDropdownMenuContent = customizationService.getCustomization(
id: 'default.disableEditing', 'panelSegmentation.customDropdownMenuContent'
disableEditing: false,
}
);
const { showAddSegment } = customizationService.getCustomization(
'PanelSegmentation.showAddSegment',
{
id: 'default.showAddSegment',
showAddSegment: true,
}
);
const CustomDropdownMenuContent = customizationService.getCustomComponent(
'PanelSegmentation.CustomDropdownMenuContent',
({
activeSegmentation,
onSegmentationAdd,
onSegmentationRemoveFromViewport,
onSegmentationEdit,
onSegmentationDelete,
allowExport,
storeSegmentation,
onSegmentationDownload,
onSegmentationDownloadRTSS,
t,
}) => {
return (
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => onSegmentationAdd(activeSegmentation.segmentationId)}>
<Icons.Add className="text-foreground" />
<span className="pl-2">{t('Create New Segmentation')}</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>{t('Manage Current Segmentation')}</DropdownMenuLabel>
<DropdownMenuItem
onClick={() => onSegmentationRemoveFromViewport(activeSegmentation.segmentationId)}
>
<Icons.Series className="text-foreground" />
<span className="pl-2">{t('Remove from Viewport')}</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onSegmentationEdit(activeSegmentation.segmentationId)}>
<Icons.Rename className="text-foreground" />
<span className="pl-2">{t('Rename')}</span>
</DropdownMenuItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={!allowExport}
className="pl-1"
>
<Icons.Export className="text-foreground" />
<span className="pl-2">{t('Export & Download')}</span>
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
<DropdownMenuItem
onClick={() => storeSegmentation(activeSegmentation.segmentationId)}
>
{t('Export DICOM SEG')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSegmentationDownload(activeSegmentation.segmentationId)}
>
{t('Download DICOM SEG')}
</DropdownMenuItem>
{/* <DropdownMenuItem
onClick={() => onSegmentationDownloadRTSS(activeSegmentation.segmentationId)}
>
{t('Download DICOM RTSTRUCT')}
</DropdownMenuItem> */}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onSegmentationDelete(activeSegmentation.segmentationId)}>
<Icons.Delete className="text-red-600" />
<span className="pl-2 text-red-600">{t('Delete')}</span>
</DropdownMenuItem>
</DropdownMenuContent>
);
}
); );
const exportOptions = segmentationsWithRepresentations.map(({ segmentation }) => { const exportOptions = segmentationsWithRepresentations.map(({ segmentation }) => {
@ -272,7 +168,7 @@ export default function PanelSegmentation({
<SegmentationTable <SegmentationTable
disabled={disabled} disabled={disabled}
data={segmentationsWithRepresentations} data={segmentationsWithRepresentations}
mode={SegmentationTableMode} mode={segmentationTableMode}
title="Segmentations" title="Segmentations"
exportOptions={exportOptions} exportOptions={exportOptions}
disableEditing={disableEditing} disableEditing={disableEditing}
@ -308,7 +204,7 @@ export default function PanelSegmentation({
<SegmentationTable.Config /> <SegmentationTable.Config />
<SegmentationTable.AddSegmentationRow /> <SegmentationTable.AddSegmentationRow />
{SegmentationTableMode === 'collapsed' ? ( {segmentationTableMode === 'collapsed' ? (
<SegmentationTable.Collapsed> <SegmentationTable.Collapsed>
<SegmentationTable.SelectorHeader> <SegmentationTable.SelectorHeader>
<CustomDropdownMenuContent /> <CustomDropdownMenuContent />

View File

@ -1,7 +1,5 @@
import { useEffect, useState, memo } from 'react'; import { useEffect, useState, memo } from 'react';
const MODALITIES_REQUIRING_CINE_AUTO_MOUNT = ['OT', 'US'];
const ActiveViewportBehavior = memo( const ActiveViewportBehavior = memo(
({ servicesManager, viewportId }: withAppTypes<{ viewportId: string }>) => { ({ servicesManager, viewportId }: withAppTypes<{ viewportId: string }>) => {
const { displaySetService, cineService, viewportGridService, customizationService } = const { displaySetService, cineService, viewportGridService, customizationService } =
@ -41,13 +39,7 @@ const ActiveViewportBehavior = memo(
const modalities = displaySets.map(displaySet => displaySet?.Modality); const modalities = displaySets.map(displaySet => displaySet?.Modality);
const isDynamicVolume = displaySets.some(displaySet => displaySet?.isDynamicVolume); const isDynamicVolume = displaySets.some(displaySet => displaySet?.isDynamicVolume);
const { modalities: sourceModalities } = customizationService.getModeCustomization( const sourceModalities = customizationService.getCustomization('autoCineModalities');
'autoCineModalities',
{
id: 'autoCineModalities',
modalities: MODALITIES_REQUIRING_CINE_AUTO_MOUNT,
}
);
const requiresCine = modalities.some(modality => sourceModalities.includes(modality)); const requiresCine = modalities.some(modality => sourceModalities.includes(modality));

View File

@ -142,7 +142,7 @@ function getMappedAnnotations(annotation, displaySetService) {
* @returns {object} Report's content. * @returns {object} Report's content.
*/ */
function getColumnValueReport(annotation, customizationService) { function getColumnValueReport(annotation, customizationService) {
const { PlanarFreehandROI } = customizationService.get('cornerstone.measurements'); const { PlanarFreehandROI } = customizationService.getCustomization('cornerstone.measurements');
const { report } = PlanarFreehandROI; const { report } = PlanarFreehandROI;
const columns = []; const columns = [];
const values = []; const values = [];

View File

@ -145,7 +145,7 @@ function getMappedAnnotations(annotation, displaySetService) {
* @returns {object} Report's content. * @returns {object} Report's content.
*/ */
function getColumnValueReport(annotation, customizationService) { function getColumnValueReport(annotation, customizationService) {
const { SplineROI } = customizationService.get('cornerstone.measurements'); const { SplineROI } = customizationService.getCustomization('cornerstone.measurements');
const { report } = SplineROI; const { report } = SplineROI;
const columns = []; const columns = [];
const values = []; const values = [];

View File

@ -29,8 +29,9 @@ function DataSourceConfigurationComponent({
return; return;
} }
const { factory: configurationAPIFactory } = const { factory: configurationAPIFactory } = customizationService.getCustomization(
customizationService.get(activeDataSourceDef.configuration.configurationAPI) ?? {}; activeDataSourceDef.configuration.configurationAPI
) ?? { factory: () => null };
if (!configurationAPIFactory) { if (!configurationAPIFactory) {
return; return;

View File

@ -56,9 +56,9 @@ export default function MoreDropdownMenu(bindProps) {
} = bindProps; } = bindProps;
const { customizationService } = servicesManager.services; const { customizationService } = servicesManager.services;
const items = customizationService.getCustomization(menuItemsKey)?.value; const items = customizationService.getCustomization(menuItemsKey);
if (!items) { if (!items?.length) {
return null; return null;
} }

View File

@ -1,34 +0,0 @@
const defaultContextMenu = {
id: 'measurementsContextMenu',
customizationType: 'ohif.contextMenu',
menus: [
// Get the items from the UI Customization for the menu name (and have a custom name)
{
id: 'forExistingMeasurement',
selector: ({ nearbyToolData }) => !!nearbyToolData,
items: [
{
label: 'Delete measurement',
commands: [
{
commandName: 'deleteMeasurement',
// we only have support for cornerstoneTools context menu since
// they are svg based
context: 'CORNERSTONE',
},
],
},
{
label: 'Add Label',
commands: [
{
commandName: 'setMeasurementLabel',
},
],
},
],
},
],
};
export default defaultContextMenu;

View File

@ -1,11 +1,5 @@
import ContextMenuController from './ContextMenuController'; import ContextMenuController from './ContextMenuController';
import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder'; import * as ContextMenuItemsBuilder from './ContextMenuItemsBuilder';
import defaultContextMenu from './defaultContextMenu';
import * as CustomizableContextMenuTypes from './types'; import * as CustomizableContextMenuTypes from './types';
export { export { ContextMenuController, CustomizableContextMenuTypes, ContextMenuItemsBuilder };
ContextMenuController,
CustomizableContextMenuTypes,
ContextMenuItemsBuilder,
defaultContextMenu,
};

View File

@ -45,10 +45,11 @@ export type UIMenuItem = {
*/ */
export interface MenuItem { export interface MenuItem {
id?: string; id?: string;
/** The customization type is used to apply preset values to this item /**
* The customization type is used to apply preset values to this item
* when registered with the customization service. * when registered with the customization service.
*/ */
customizationType?: string; inheritsFrom?: string;
// The label is the value to show in the menu for this item // The label is the value to show in the menu for this item
label?: string; label?: string;
@ -91,7 +92,7 @@ export interface Menu {
/** The customization type is used to apply preset values to this item /** The customization type is used to apply preset values to this item
* when registered with the customization service. * when registered with the customization service.
*/ */
customizationType?: string; inheritsFrom?: string;
// Choose whether this menu applies. // Choose whether this menu applies.
selector?: Types.Predicate; selector?: Types.Predicate;

View File

@ -582,8 +582,11 @@ function createDicomWebApi(dicomWebConfig: DicomWebConfig, servicesManager) {
return dicomWebConfigCopy; return dicomWebConfigCopy;
}, },
getStudyInstanceUIDs({ params, query }) { getStudyInstanceUIDs({ params, query }) {
const { StudyInstanceUIDs: paramsStudyInstanceUIDs } = params; const paramsStudyInstanceUIDs = params.StudyInstanceUIDs || params.studyInstanceUIDs;
const queryStudyInstanceUIDs = utils.splitComma(query.getAll('StudyInstanceUIDs'));
const queryStudyInstanceUIDs = utils.splitComma(
query.getAll('StudyInstanceUIDs').concat(query.getAll('studyInstanceUIDs'))
);
const StudyInstanceUIDs = const StudyInstanceUIDs =
(queryStudyInstanceUIDs.length && queryStudyInstanceUIDs) || paramsStudyInstanceUIDs; (queryStudyInstanceUIDs.length && queryStudyInstanceUIDs) || paramsStudyInstanceUIDs;

View File

@ -6,7 +6,7 @@ import { utils } from '@ohif/core';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Separator } from '@ohif/ui-next'; import { Separator } from '@ohif/ui-next';
import { PanelStudyBrowserHeader } from './PanelStudyBrowserHeader'; import { PanelStudyBrowserHeader } from './PanelStudyBrowserHeader';
import { defaultActionIcons, defaultViewPresets } from './constants'; import { defaultActionIcons } from './constants';
import MoreDropdownMenu from '../../Components/MoreDropdownMenu'; import MoreDropdownMenu from '../../Components/MoreDropdownMenu';
const { sortStudyInstances, formatDate, createStudyBrowserTabs } = utils; const { sortStudyInstances, formatDate, createStudyBrowserTabs } = utils;
@ -43,7 +43,7 @@ function PanelStudyBrowser({
const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({}); const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({});
const [viewPresets, setViewPresets] = useState( const [viewPresets, setViewPresets] = useState(
customizationService.getCustomization('studyBrowser.viewPresets')?.value || defaultViewPresets customizationService.getCustomization('studyBrowser.viewPresets')
); );
const [actionIcons, setActionIcons] = useState(defaultActionIcons); const [actionIcons, setActionIcons] = useState(defaultActionIcons);

View File

@ -2,87 +2,6 @@ import React, { useEffect, useState, useCallback, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { LayoutSelector as OHIFLayoutSelector, ToolbarButton, LayoutPreset } from '@ohif/ui'; import { LayoutSelector as OHIFLayoutSelector, ToolbarButton, LayoutPreset } from '@ohif/ui';
const defaultCommonPresets = [
{
icon: 'layout-common-1x1',
commandOptions: {
numRows: 1,
numCols: 1,
},
},
{
icon: 'layout-common-1x2',
commandOptions: {
numRows: 1,
numCols: 2,
},
},
{
icon: 'layout-common-2x2',
commandOptions: {
numRows: 2,
numCols: 2,
},
},
{
icon: 'layout-common-2x3',
commandOptions: {
numRows: 2,
numCols: 3,
},
},
];
const _areSelectorsValid = (hp, displaySets, hangingProtocolService) => {
if (!hp.displaySetSelectors || Object.values(hp.displaySetSelectors).length === 0) {
return true;
}
return hangingProtocolService.areRequiredSelectorsValid(
Object.values(hp.displaySetSelectors),
displaySets[0]
);
};
const generateAdvancedPresets = ({ servicesManager }: withAppTypes) => {
const { hangingProtocolService, viewportGridService, displaySetService } =
servicesManager.services;
const hangingProtocols = Array.from(hangingProtocolService.protocols.values());
const viewportId = viewportGridService.getActiveViewportId();
if (!viewportId) {
return [];
}
const displaySetInsaneUIDs = viewportGridService.getDisplaySetsUIDsForViewport(viewportId);
if (!displaySetInsaneUIDs) {
return [];
}
const displaySets = displaySetInsaneUIDs.map(uid => displaySetService.getDisplaySetByUID(uid));
return hangingProtocols
.map(hp => {
if (!hp.isPreset) {
return null;
}
const areValid = _areSelectorsValid(hp, displaySets, hangingProtocolService);
return {
icon: hp.icon,
title: hp.name,
commandOptions: {
protocolId: hp.id,
},
disabled: !areValid,
};
})
.filter(preset => preset !== null);
};
function ToolbarLayoutSelectorWithServices({ function ToolbarLayoutSelectorWithServices({
commandsManager, commandsManager,
servicesManager, servicesManager,
@ -138,9 +57,13 @@ function LayoutSelector({
const dropdownRef = useRef(null); const dropdownRef = useRef(null);
const { customizationService } = servicesManager.services; const { customizationService } = servicesManager.services;
const commonPresets = customizationService.get('commonPresets') || defaultCommonPresets;
const advancedPresets = const commonPresets = customizationService.getCustomization('layoutSelector.commonPresets');
customizationService.get('advancedPresets') || generateAdvancedPresets({ servicesManager }); const advancedPresetsGenerator = customizationService.getCustomization(
'layoutSelector.advancedPresetGenerator'
);
const advancedPresets = advancedPresetsGenerator({ servicesManager });
const closeOnOutsideClick = event => { const closeOnOutsideClick = event => {
if (isOpen && dropdownRef.current) { if (isOpen && dropdownRef.current) {

View File

@ -1,6 +1,6 @@
import { Types, DicomMetadataStore } from '@ohif/core'; import { Types, DicomMetadataStore } from '@ohif/core';
import { ContextMenuController, defaultContextMenu } from './CustomizableContextMenu'; import { ContextMenuController } from './CustomizableContextMenu';
import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser'; import DicomTagBrowser from './DicomTagBrowser/DicomTagBrowser';
import reuseCachedLayouts from './utils/reuseCachedLayouts'; import reuseCachedLayouts from './utils/reuseCachedLayouts';
import findViewportsByPosition, { import findViewportsByPosition, {
@ -113,10 +113,7 @@ const commandsModule = ({
const optionsToUse = { ...options }; const optionsToUse = { ...options };
if (menuCustomizationId) { if (menuCustomizationId) {
Object.assign( Object.assign(optionsToUse, customizationService.getCustomization(menuCustomizationId));
optionsToUse,
customizationService.get(menuCustomizationId, defaultContextMenu)
);
} }
// TODO - make the selectorProps richer by including the study metadata and display set. // TODO - make the selectorProps richer by including the study metadata and display set.

View File

@ -0,0 +1,25 @@
import { CustomizationService } from '@ohif/core';
export default {
'ohif.contextMenu': {
$transform: function (customizationService: CustomizationService) {
/**
* Applies the inheritsFrom to all the menu items.
* This function clones the object and child objects to prevent
* changes to the original customization object.
*/
// Don't modify the children, as those are copied by reference
const clonedObject = { ...this };
clonedObject.menus = this.menus.map(menu => ({ ...menu }));
for (const menu of clonedObject.menus) {
const { items: originalItems } = menu;
menu.items = [];
for (const item of originalItems) {
menu.items.push(customizationService.transform(item));
}
}
return clonedObject;
},
},
};

View File

@ -0,0 +1,6 @@
export default {
'routes.customRoutes': {
routes: [],
notFoundRoute: null,
},
};

View File

@ -0,0 +1,19 @@
import DataSourceConfigurationComponent from '../Components/DataSourceConfigurationComponent';
import { GoogleCloudDataSourceConfigurationAPI } from '../DataSourceConfigurationAPI/GoogleCloudDataSourceConfigurationAPI';
export default function getDataSourceConfigurationCustomization({
servicesManager,
extensionManager,
}) {
return {
// the generic GUI component to configure a data source using an instance of a BaseDataSourceConfigurationAPI
'ohif.dataSourceConfigurationComponent': DataSourceConfigurationComponent.bind(null, {
servicesManager,
extensionManager,
}),
// The factory for creating an instance of a BaseDataSourceConfigurationAPI for Google Cloud Healthcare
'ohif.dataSourceConfigurationAPI.google': (dataSourceName: string) =>
new GoogleCloudDataSourceConfigurationAPI(dataSourceName, servicesManager, extensionManager),
};
}

View File

@ -0,0 +1,14 @@
import DataSourceSelector from '../Panels/DataSourceSelector';
export default {
'routes.customRoutes': {
routes: {
$push: [
{
path: '/datasources',
children: DataSourceSelector,
},
],
},
},
};

View File

@ -0,0 +1,22 @@
export default {
measurementsContextMenu: {
inheritsFrom: 'ohif.contextMenu',
menus: [
// Get the items from the UI Customization for the menu name (and have a custom name)
{
id: 'forExistingMeasurement',
selector: ({ nearbyToolData }) => !!nearbyToolData,
items: [
{
label: 'Delete measurement',
commands: 'deleteMeasurement',
},
{
label: 'Add Label',
commands: 'setMeasurementLabel',
},
],
},
],
},
};

View File

@ -0,0 +1,14 @@
import React from 'react';
export default {
'routes.customRoutes': {
routes: {
$push: [
{
path: '/custom',
children: () => <h1 style={{ color: 'white' }}>Hello Custom Route</h1>,
},
],
},
},
};

View File

@ -0,0 +1,67 @@
import React from 'react';
import {
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuPortal,
DropdownMenuSubContent,
DropdownMenuItem,
Icons,
} from '@ohif/ui-next';
export default {
'ohif.menuContent': function (props) {
const { item: topLevelItem, commandsManager, servicesManager, ...rest } = props;
const content = function (subProps) {
const { item: subItem } = subProps;
// Regular menu item
const isDisabled = subItem.selector && !subItem.selector({ servicesManager });
return (
<DropdownMenuItem
disabled={isDisabled}
onSelect={() => {
commandsManager.runAsync(subItem.commands, {
...subItem.commandOptions,
...rest,
});
}}
className="gap-[6px]"
>
{subItem.iconName && (
<Icons.ByName
name={subItem.iconName}
className="-ml-1"
/>
)}
{subItem.label}
</DropdownMenuItem>
);
};
// If item has sub-items, render a submenu
if (topLevelItem.items) {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-[6px]">
{topLevelItem.iconName && (
<Icons.ByName
name={topLevelItem.iconName}
className="-ml-1"
/>
)}
{topLevelItem.label}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{topLevelItem.items.map(subItem => content({ ...props, item: subItem }))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
}
return content({ ...props, item: topLevelItem });
},
};

View File

@ -0,0 +1,63 @@
export default {
'studyBrowser.studyMenuItems': {
$push: [
{
id: 'applyHangingProtocol',
label: 'Apply Hanging Protocol',
iconName: 'ViewportViews',
items: [
{
id: 'applyDefaultProtocol',
label: 'Default',
commands: [
'loadStudy',
{
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: 'default',
},
},
],
},
{
id: 'applyMPRProtocol',
label: '2x2 Grid',
commands: [
'loadStudy',
{
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: '@ohif/mnGrid',
},
},
],
},
],
},
{
id: 'showInOtherMonitor',
label: 'Launch On Second Monitor',
iconName: 'DicomTagBrowser',
selector: ({ servicesManager }) => {
const { multiMonitorService } = servicesManager.services;
return multiMonitorService.isMultimonitor;
},
commands: {
commandName: 'multimonitor',
commandOptions: {
hashParams: '&hangingProtocolId=@ohif/mnGrid8',
commands: [
'loadStudy',
{
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: '@ohif/mnGrid8',
},
},
],
},
},
},
],
},
};

View File

@ -0,0 +1,31 @@
import React from 'react';
export default {
'ohif.overlayItem': function (props) {
if (this.condition && !this.condition(props)) {
return null;
}
const { instance } = props;
const value =
instance && this.attribute
? instance[this.attribute]
: this.contentF && typeof this.contentF === 'function'
? this.contentF(props)
: null;
if (!value) {
return null;
}
return (
<span
className="overlay-item flex flex-row"
style={{ color: this.color || undefined }}
title={this.title || ''}
>
{this.label && <span className="mr-1 shrink-0">{this.label}</span>}
<span className="font-light">{value}</span>
</span>
);
},
};

View File

@ -0,0 +1,5 @@
import { ProgressDropdownWithService } from '../Components/ProgressDropdownWithService';
export default {
progressDropdownWithServiceComponent: ProgressDropdownWithService,
};

View File

@ -0,0 +1,7 @@
import { utils } from '@ohif/core';
const { sortingCriteria } = utils;
export default {
sortingCriteria: sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria,
};

View File

@ -0,0 +1,43 @@
import { utils } from '@ohif/core';
const { formatDate } = utils;
export default {
'studyBrowser.studyMenuItems': [],
'studyBrowser.thumbnailMenuItems': [
{
id: 'tagBrowser',
label: 'Tag Browser',
iconName: 'DicomTagBrowser',
commands: 'openDICOMTagViewer',
},
],
'studyBrowser.sortFunctions': [
{
label: 'Series Number',
sortFunction: (a, b) => {
return a?.SeriesNumber - b?.SeriesNumber;
},
},
{
label: 'Series Date',
sortFunction: (a, b) => {
const dateA = new Date(formatDate(a?.SeriesDate));
const dateB = new Date(formatDate(b?.SeriesDate));
return dateB.getTime() - dateA.getTime();
},
},
],
'studyBrowser.viewPresets': [
{
id: 'list',
iconName: 'ListView',
selected: false,
},
{
id: 'thumbnails',
iconName: 'ThumbnailView',
selected: true,
},
],
'studyBrowser.studyMode': 'all',
};

View File

@ -1,20 +1,15 @@
import { CustomizationService } from '@ohif/core'; import defaultContextMenuCustomization from './customizations/defaultContextMenuCustomization';
import React from 'react'; import helloPageCustomization from './customizations/helloPageCustomization';
import DataSourceSelector from './Panels/DataSourceSelector'; import datasourcesCustomization from './customizations/datasourcesCustomization';
import { ProgressDropdownWithService } from './Components/ProgressDropdownWithService'; import multimonitorCustomization from './customizations/multimonitorCustomization';
import DataSourceConfigurationComponent from './Components/DataSourceConfigurationComponent'; import customRoutesCustomization from './customizations/customRoutesCustomization';
import { GoogleCloudDataSourceConfigurationAPI } from './DataSourceConfigurationAPI/GoogleCloudDataSourceConfigurationAPI'; import studyBrowserCustomization from './customizations/studyBrowserCustomization';
import { utils } from '@ohif/core'; import overlayItemCustomization from './customizations/overlayItemCustomization';
import { import contextMenuCustomization from './customizations/contextMenuCustomization';
DropdownMenuSub, import menuContentCustomization from './customizations/menuContentCustomization';
DropdownMenuSubTrigger, import getDataSourceConfigurationCustomization from './customizations/dataSourceConfigurationCustomization';
DropdownMenuPortal, import progressDropdownCustomization from './customizations/progressDropdownCustomization';
DropdownMenuSubContent, import sortingCriteriaCustomization from './customizations/sortingCriteriaCustomization';
DropdownMenuItem,
Icons,
} from '@ohif/ui-next';
const formatDate = utils.formatDate;
/** /**
* *
@ -29,315 +24,29 @@ export default function getCustomizationModule({ servicesManager, extensionManag
return [ return [
{ {
name: 'helloPage', name: 'helloPage',
merge: 'Append', value: helloPageCustomization,
value: {
id: 'customRoutes',
routes: [
{
path: '/custom',
children: () => <h1 style={{ color: 'white' }}>Hello Custom Route</h1>,
}, },
],
},
},
// Example customization to list a set of datasources
{ {
name: 'datasources', name: 'datasources',
merge: 'Append', value: datasourcesCustomization,
value: {
id: 'customRoutes',
routes: [
{
path: '/datasources',
children: DataSourceSelector,
},
],
},
}, },
{ {
name: 'multimonitor', name: 'multimonitor',
merge: 'Append', value: multimonitorCustomization,
value: {
id: 'studyBrowser.studyMenuItems',
customizationType: 'ohif.menuContent',
value: [
{
id: 'applyHangingProtocol',
label: 'Apply Hanging Protocol',
iconName: 'ViewportViews',
items: [
{
id: 'applyDefaultProtocol',
label: 'Default',
commands: [
'loadStudy',
{
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: 'default',
},
},
],
},
{
id: 'applyMPRProtocol',
label: '2x2 Grid',
commands: [
'loadStudy',
{
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: '@ohif/mnGrid',
},
},
],
},
],
},
{
id: 'showInOtherMonitor',
label: 'Launch On Second Monitor',
iconName: 'DicomTagBrowser',
// we should use evaluator for this, as these are basically toolbar buttons
selector: ({ servicesManager }) => {
const { multiMonitorService } = servicesManager.services;
return multiMonitorService.isMultimonitor;
},
commands: {
commandName: 'multimonitor',
commandOptions: {
hashParams: '&hangingProtocolId=@ohif/mnGrid8',
commands: [
'loadStudy',
{
commandName: 'setHangingProtocol',
commandOptions: {
protocolId: '@ohif/mnGrid8',
},
},
],
},
},
},
],
},
}, },
{ {
name: 'default', name: 'default',
value: [ value: {
/** ...customRoutesCustomization,
* Customization Component Type definition for overlay items. ...studyBrowserCustomization,
* Overlay items are texts (or other components) that will be displayed ...overlayItemCustomization,
* on a Viewport Overlay, which contains the information panels on the ...contextMenuCustomization,
* four corners of a viewport. ...menuContentCustomization,
* ...getDataSourceConfigurationCustomization({ servicesManager, extensionManager }),
* @definition of a overlay item using this type ...progressDropdownCustomization,
* The value to be displayed is defined by ...sortingCriteriaCustomization,
* - setting DICOM image instance's property to this field, ...defaultContextMenuCustomization,
* - or defining contentF()
*
* {
* id: string - unique id for the overlay item
* customizationType: string - indicates customization type definition to this
* label: string - Label, to be displayed for the item
* title: string - Tooltip, for the item
* color: string - Color of the text
* condition: ({ instance }) => boolean - decides whether to display the overlay item or not
* attribute: string - property name of the DICOM image instance
* contentF: ({ instance, formatters }) => string | component,
* }
*
* @example
* {
* id: 'PatientNameOverlay',
* customizationType: 'ohif.overlayItem',
* label: 'PN:',
* title: 'Patient Name',
* color: 'yellow',
* condition: ({ instance }) => instance && instance.PatientName && instance.PatientName.Alphabetic,
* attribute: 'PatientName',
* contentF: ({ instance, formatters: { formatPN } }) => `${formatPN(instance.PatientName.Alphabetic)} ${(instance.PatientSex ? '(' + instance.PatientSex + ')' : '')}`,
* },
*
* @see CustomizableViewportOverlay
*/
{
id: 'ohif.overlayItem',
content: function (props) {
if (this.condition && !this.condition(props)) {
return null;
}
const { instance } = props;
const value =
instance && this.attribute
? instance[this.attribute]
: this.contentF && typeof this.contentF === 'function'
? this.contentF(props)
: null;
if (!value) {
return null;
}
return (
<span
className="overlay-item flex flex-row"
style={{ color: this.color || undefined }}
title={this.title || ''}
>
{this.label && <span className="mr-1 shrink-0">{this.label}</span>}
<span className="font-light">{value}</span>
</span>
);
}, },
}, },
{
id: 'ohif.contextMenu',
/** Applies the customizationType to all the menu items.
* This function clones the object and child objects to prevent
* changes to the original customization object.
*/
transform: function (customizationService: CustomizationService) {
// Don't modify the children, as those are copied by reference
const clonedObject = { ...this };
clonedObject.menus = this.menus.map(menu => ({ ...menu }));
for (const menu of clonedObject.menus) {
const { items: originalItems } = menu;
menu.items = [];
for (const item of originalItems) {
menu.items.push(customizationService.transform(item));
}
}
return clonedObject;
},
},
{
// the generic GUI component to configure a data source using an instance of a BaseDataSourceConfigurationAPI
id: 'ohif.dataSourceConfigurationComponent',
component: DataSourceConfigurationComponent.bind(null, {
servicesManager,
extensionManager,
}),
},
{
// The factory for creating an instance of a BaseDataSourceConfigurationAPI for Google Cloud Healthcare
id: 'ohif.dataSourceConfigurationAPI.google',
factory: (dataSourceName: string) =>
new GoogleCloudDataSourceConfigurationAPI(
dataSourceName,
servicesManager,
extensionManager
),
},
{
id: 'progressDropdownWithServiceComponent',
component: ProgressDropdownWithService,
},
{
id: 'studyBrowser.sortFunctions',
values: [
{
label: 'Series Number',
sortFunction: (a, b) => {
return a?.SeriesNumber - b?.SeriesNumber;
},
},
{
label: 'Series Date',
sortFunction: (a, b) => {
const dateA = new Date(formatDate(a?.SeriesDate));
const dateB = new Date(formatDate(b?.SeriesDate));
return dateB.getTime() - dateA.getTime();
},
},
],
},
{
id: 'studyBrowser.viewPresets',
// change your default selected preset here
value: [
{
id: 'list',
iconName: 'ListView',
selected: false,
},
{
id: 'thumbnails',
iconName: 'ThumbnailView',
selected: true,
},
],
},
{
id: 'studyBrowser.thumbnailMenuItems',
value: [
{
id: 'tagBrowser',
label: 'Tag Browser',
iconName: 'DicomTagBrowser',
commands: 'openDICOMTagViewer',
},
],
},
{
id: 'ohif.menuContent',
content: function (props) {
const { item, commandsManager, servicesManager, ...rest } = props;
// If item has sub-items, render a submenu
if (item.items) {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger className="gap-[6px]">
{item.iconName && (
<Icons.ByName
name={item.iconName}
className="-ml-1"
/>
)}
{item.label}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{item.items.map(subItem => this.content({ ...props, item: subItem }))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
}
// Regular menu item
const isDisabled = item.selector && !item.selector({ servicesManager });
return (
<DropdownMenuItem
disabled={isDisabled}
onSelect={() => {
commandsManager.runAsync(item.commands, {
...item.commandOptions,
...rest,
});
}}
className="gap-[6px]"
>
{item.iconName && (
<Icons.ByName
name={item.iconName}
className="-ml-1"
/>
)}
{item.label}
</DropdownMenuItem>
);
},
},
],
},
]; ];
} }

View File

@ -4,7 +4,7 @@ function promptLabelAnnotation({ servicesManager }, ctx, evt) {
const { measurementService, customizationService } = servicesManager.services; const { measurementService, customizationService } = servicesManager.services;
const { viewportId, StudyInstanceUID, SeriesInstanceUID, measurementId } = evt; const { viewportId, StudyInstanceUID, SeriesInstanceUID, measurementId } = evt;
return new Promise(async function (resolve) { return new Promise(async function (resolve) {
const labelConfig = customizationService.get('measurementLabels'); const labelConfig = customizationService.getCustomization('measurementLabels');
const measurement = measurementService.getMeasurement(measurementId); const measurement = measurementService.getMeasurement(measurementId);
const value = await showLabelAnnotationPopup( const value = await showLabelAnnotationPopup(
measurement, measurement,

View File

@ -159,6 +159,7 @@ function TrackedMeasurementsContextProvider(
promptHydrateStructuredReport: promptHydrateStructuredReport.bind(null, { promptHydrateStructuredReport: promptHydrateStructuredReport.bind(null, {
servicesManager, servicesManager,
extensionManager, extensionManager,
commandsManager,
appConfig, appConfig,
}), }),
hydrateStructuredReport: hydrateStructuredReport.bind(null, { hydrateStructuredReport: hydrateStructuredReport.bind(null, {
@ -173,11 +174,11 @@ function TrackedMeasurementsContextProvider(
}); });
machineOptions.guards = Object.assign({}, machineOptions.guards, { machineOptions.guards = Object.assign({}, machineOptions.guards, {
isLabelOnMeasure: (ctx, evt, condMeta) => { isLabelOnMeasure: (ctx, evt, condMeta) => {
const labelConfig = customizationService.get('measurementLabels'); const labelConfig = customizationService.getCustomization('measurementLabels');
return labelConfig?.labelOnMeasure; return labelConfig?.labelOnMeasure;
}, },
isLabelOnMeasureAndShouldKillMachine: (ctx, evt, condMeta) => { isLabelOnMeasureAndShouldKillMachine: (ctx, evt, condMeta) => {
const labelConfig = customizationService.get('measurementLabels'); const labelConfig = customizationService.getCustomization('measurementLabels');
return evt.data && evt.data.userResponse === RESPONSE.NO_NEVER && labelConfig?.labelOnMeasure; return evt.data && evt.data.userResponse === RESPONSE.NO_NEVER && labelConfig?.labelOnMeasure;
}, },
}); });

View File

@ -1,7 +1,7 @@
import { hydrateStructuredReport as baseHydrateStructuredReport } from '@ohif/extension-cornerstone-dicom-sr'; import { hydrateStructuredReport as baseHydrateStructuredReport } from '@ohif/extension-cornerstone-dicom-sr';
function hydrateStructuredReport( function hydrateStructuredReport(
{ servicesManager, extensionManager, appConfig }: withAppTypes, { servicesManager, extensionManager, commandsManager, appConfig }: withAppTypes,
ctx, ctx,
evt evt
) { ) {
@ -11,7 +11,7 @@ function hydrateStructuredReport(
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const hydrationResult = baseHydrateStructuredReport( const hydrationResult = baseHydrateStructuredReport(
{ servicesManager, extensionManager, appConfig }, { servicesManager, extensionManager, commandsManager, appConfig },
displaySetInstanceUID displaySetInstanceUID
); );

View File

@ -11,7 +11,11 @@ const RESPONSE = {
HYDRATE_REPORT: 5, HYDRATE_REPORT: 5,
}; };
function promptHydrateStructuredReport({ servicesManager, extensionManager, appConfig }, ctx, evt) { function promptHydrateStructuredReport(
{ servicesManager, extensionManager, commandsManager, appConfig },
ctx,
evt
) {
const { uiViewportDialogService, displaySetService } = servicesManager.services; const { uiViewportDialogService, displaySetService } = servicesManager.services;
const { viewportId, displaySetInstanceUID } = evt; const { viewportId, displaySetInstanceUID } = evt;
const srDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID); const srDisplaySet = displaySetService.getDisplaySetByUID(displaySetInstanceUID);
@ -26,7 +30,7 @@ function promptHydrateStructuredReport({ servicesManager, extensionManager, appC
if (promptResult === RESPONSE.HYDRATE_REPORT) { if (promptResult === RESPONSE.HYDRATE_REPORT) {
console.warn('!! HYDRATING STRUCTURED REPORT'); console.warn('!! HYDRATING STRUCTURED REPORT');
const hydrationResult = hydrateStructuredReport( const hydrationResult = hydrateStructuredReport(
{ servicesManager, extensionManager, appConfig }, { servicesManager, extensionManager, commandsManager, appConfig },
displaySetInstanceUID displaySetInstanceUID
); );

View File

@ -5,7 +5,7 @@ import { Button, Icons } from '@ohif/ui-next';
import { PanelMeasurement, StudySummaryFromMetadata } from '@ohif/extension-cornerstone'; import { PanelMeasurement, StudySummaryFromMetadata } from '@ohif/extension-cornerstone';
import { useTrackedMeasurements } from '../getContextModule'; import { useTrackedMeasurements } from '../getContextModule';
const { filterAnd, filterPlanarMeasurement, filterAny, filterMeasurementsBySeriesUID } = const { filterAnd, filterPlanarMeasurement, filterMeasurementsBySeriesUID } =
utils.MeasurementFilters; utils.MeasurementFilters;
function PanelMeasurementTableTracking({ function PanelMeasurementTableTracking({
@ -21,13 +21,7 @@ function PanelMeasurementTableTracking({
? filterAnd(filterPlanarMeasurement, filterMeasurementsBySeriesUID(trackedSeries)) ? filterAnd(filterPlanarMeasurement, filterMeasurementsBySeriesUID(trackedSeries))
: filterPlanarMeasurement; : filterPlanarMeasurement;
const { disableEditing } = customizationService.getCustomization( const disableEditing = customizationService.getCustomization('panelMeasurement.disableEditing');
'PanelMeasurement.disableEditing',
{
id: 'default.disableEditing',
disableEditing: false,
}
);
return ( return (
<> <>

View File

@ -4,14 +4,15 @@ import { useTranslation } from 'react-i18next';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { utils } from '@ohif/core'; import { utils } from '@ohif/core';
import { useImageViewer, Dialog, ButtonEnums } from '@ohif/ui'; import { useImageViewer, Dialog, ButtonEnums } from '@ohif/ui';
import { useViewportGrid, DropdownMenu, DropdownMenuTrigger, Icons, Button } from '@ohif/ui-next'; import { useViewportGrid } from '@ohif/ui-next';
import { StudyBrowser } from '@ohif/ui-next'; import { StudyBrowser } from '@ohif/ui-next';
import { useTrackedMeasurements } from '../../getContextModule'; import { useTrackedMeasurements } from '../../getContextModule';
import { Separator } from '@ohif/ui-next'; import { Separator } from '@ohif/ui-next';
import { PanelStudyBrowserHeader, MoreDropdownMenu } from '@ohif/extension-default'; import { MoreDropdownMenu, PanelStudyBrowserHeader } from '@ohif/extension-default';
import { defaultActionIcons, defaultViewPresets } from './constants'; import { defaultActionIcons } from './constants';
const { formatDate, createStudyBrowserTabs } = utils; const { formatDate, createStudyBrowserTabs } = utils;
const thumbnailNoImageModalities = [ const thumbnailNoImageModalities = [
'SR', 'SR',
'SEG', 'SEG',
@ -46,10 +47,7 @@ export default function PanelStudyBrowserTracking({
customizationService, customizationService,
} = servicesManager.services; } = servicesManager.services;
const navigate = useNavigate(); const navigate = useNavigate();
const { mode: studyMode } = customizationService.getCustomization('PanelStudyBrowser.studyMode', { const studyMode = customizationService.getCustomization('studyBrowser.studyMode');
id: 'default',
mode: 'all',
});
const { t } = useTranslation('Common'); const { t } = useTranslation('Common');
@ -73,7 +71,7 @@ export default function PanelStudyBrowserTracking({
const [jumpToDisplaySet, setJumpToDisplaySet] = useState(null); const [jumpToDisplaySet, setJumpToDisplaySet] = useState(null);
const [viewPresets, setViewPresets] = useState( const [viewPresets, setViewPresets] = useState(
customizationService.getCustomization('studyBrowser.viewPresets')?.value || defaultViewPresets customizationService.getCustomization('studyBrowser.viewPresets')
); );
const [actionIcons, setActionIcons] = useState(defaultActionIcons); const [actionIcons, setActionIcons] = useState(defaultActionIcons);

View File

@ -16,9 +16,8 @@
* See https://dicom.nema.org/medical/dicom/current/output/html/part16.html * See https://dicom.nema.org/medical/dicom/current/output/html/part16.html
* for definitions of SCT and other code values. * for definitions of SCT and other code values.
*/ */
const codingValues = { export default {
id: 'codingValues', codingValues: {
// Sites // Sites
'SCT:69536005': { 'SCT:69536005': {
text: 'Head', text: 'Head',
@ -91,6 +90,5 @@ const codingValues = {
color: '#000000', color: '#000000',
type: 'orientation', type: 'orientation',
}, },
},
}; };
export default codingValues;

View File

@ -1,13 +1,12 @@
const codeMenuItem = { export default {
id: '@ohif/contextMenuAnnotationCode', '@ohif/contextMenuAnnotationCode': {
/** Applies the code value setup for this item */ /** Applies the code value setup for this item */
transform: function (customizationService) { $transform: function (customizationService) {
const { code: codeRef } = this; const { code: codeRef } = this;
if (!codeRef) { if (!codeRef) {
throw new Error(`item ${this} has no code ref`); throw new Error(`item ${this} has no code ref`);
} }
const codingValues = customizationService.get('codingValues'); const codingValues = customizationService.getCustomization('codingValues');
const code = codingValues[codeRef]; const code = codingValues[codeRef];
return { return {
...this, ...this,
@ -21,6 +20,5 @@ const codeMenuItem = {
], ],
}; };
}, },
},
}; };
export default codeMenuItem;

View File

@ -1,26 +1,24 @@
const findingsContextMenu = { export default {
id: 'measurementsContextMenu', measurementsContextMenu: {
customizationType: 'ohif.contextMenu', $set: {
inheritsFrom: 'ohif.contextMenu',
menus: [ menus: [
{ {
id: 'forExistingMeasurement',
// selector restricts context menu to when there is nearbyToolData // selector restricts context menu to when there is nearbyToolData
selector: ({ nearbyToolData }) => !!nearbyToolData, selector: ({ nearbyToolData }) => !!nearbyToolData,
items: [ items: [
{ {
customizationType: 'ohif.contextSubMenu',
label: 'Site', label: 'Site',
actionType: 'ShowSubMenu', actionType: 'ShowSubMenu',
subMenu: 'siteSelectionSubMenu', subMenu: 'siteSelectionSubMenu',
}, },
{ {
customizationType: 'ohif.contextSubMenu',
label: 'Finding', label: 'Finding',
actionType: 'ShowSubMenu', actionType: 'ShowSubMenu',
subMenu: 'findingSelectionSubMenu', subMenu: 'findingSelectionSubMenu',
}, },
{ {
// customizationType is implicit here in the configuration setup // inheritsFrom is implicit here in the configuration setup
label: 'Delete Measurement', label: 'Delete Measurement',
commands: [ commands: [
{ {
@ -52,14 +50,14 @@ const findingsContextMenu = {
{ {
id: 'orientationSelectionSubMenu', id: 'orientationSelectionSubMenu',
selector: ({ nearbyToolData }) => false, selector: ({ nearbyToolData }) => !!nearbyToolData,
items: [ items: [
{ {
customizationType: '@ohif/contextMenuAnnotationCode', inheritsFrom: '@ohif/contextMenuAnnotationCode',
code: 'SCT:24422004', code: 'SCT:24422004',
}, },
{ {
customizationType: '@ohif/contextMenuAnnotationCode', inheritsFrom: '@ohif/contextMenuAnnotationCode',
code: 'SCT:81654009', code: 'SCT:81654009',
}, },
], ],
@ -67,14 +65,14 @@ const findingsContextMenu = {
{ {
id: 'findingSelectionSubMenu', id: 'findingSelectionSubMenu',
selector: ({ nearbyToolData }) => false, selector: ({ nearbyToolData }) => !!nearbyToolData,
items: [ items: [
{ {
customizationType: '@ohif/contextMenuAnnotationCode', inheritsFrom: '@ohif/contextMenuAnnotationCode',
code: 'SCT:371861004', code: 'SCT:371861004',
}, },
{ {
customizationType: '@ohif/contextMenuAnnotationCode', inheritsFrom: '@ohif/contextMenuAnnotationCode',
code: 'SCT:194983005', code: 'SCT:194983005',
}, },
], ],
@ -85,16 +83,16 @@ const findingsContextMenu = {
selector: ({ nearbyToolData }) => !!nearbyToolData, selector: ({ nearbyToolData }) => !!nearbyToolData,
items: [ items: [
{ {
customizationType: '@ohif/contextMenuAnnotationCode', inheritsFrom: '@ohif/contextMenuAnnotationCode',
code: 'SCT:69536005', code: 'SCT:69536005',
}, },
{ {
customizationType: '@ohif/contextMenuAnnotationCode', inheritsFrom: '@ohif/contextMenuAnnotationCode',
code: 'SCT:45048000', code: 'SCT:45048000',
}, },
], ],
}, },
], ],
},
},
}; };
export default findingsContextMenu;

View File

@ -4,11 +4,17 @@ export default function getCustomizationModule() {
return [ return [
{ {
name: 'custom-context-menu', name: 'custom-context-menu',
value: [codingValues, contextMenuCodeItem, findingsContextMenu], value: {
...codingValues,
...contextMenuCodeItem,
...findingsContextMenu,
},
}, },
{ {
name: 'contextMenuCodeItem', name: 'contextMenuCodeItem',
value: [contextMenuCodeItem], value: {
...contextMenuCodeItem,
},
}, },
]; ];
} }

View File

@ -5,13 +5,12 @@ import { id } from './id';
import hpTestSwitch from './hpTestSwitch'; import hpTestSwitch from './hpTestSwitch';
import getCustomizationModule from './getCustomizationModule'; import getCustomizationModule from './getCustomizationModule';
// import {setViewportZoomPan, storeViewportZoomPan } from './custom-viewport/setViewportZoomPan';
import sameAs from './custom-attribute/sameAs'; import sameAs from './custom-attribute/sameAs';
import numberOfDisplaySets from './custom-attribute/numberOfDisplaySets'; import numberOfDisplaySets from './custom-attribute/numberOfDisplaySets';
import maxNumImageFrames from './custom-attribute/maxNumImageFrames'; import maxNumImageFrames from './custom-attribute/maxNumImageFrames';
/** /**
* The test extension provides additional behaviour for testing various * The test extension provides additional behavior for testing various
* customizations and settings for OHIF. * customizations and settings for OHIF.
*/ */
const testExtension: Types.Extensions.Extension = { const testExtension: Types.Extensions.Extension = {
@ -20,7 +19,8 @@ const testExtension: Types.Extensions.Extension = {
*/ */
id, id,
/** Register additional behaviour: /**
* Register additional behavior:
* * HP custom attribute seriesDescriptions to retrieve an array of all series descriptions * * HP custom attribute seriesDescriptions to retrieve an array of all series descriptions
* * HP custom attribute numberOfDisplaySets to retrieve the number of display sets * * HP custom attribute numberOfDisplaySets to retrieve the number of display sets
* * HP custom attribute numberOfDisplaySetsWithImages to retrieve the number of display sets containing images * * HP custom attribute numberOfDisplaySetsWithImages to retrieve the number of display sets containing images

View File

@ -67,7 +67,7 @@ export default function PanelRoiThresholdSegmentation({
<span className="text-base font-bold uppercase tracking-widest text-white"> <span className="text-base font-bold uppercase tracking-widest text-white">
{'TMTV:'} {'TMTV:'}
</span> </span>
<div className="text-white">{`${tmtvValue.toFixed(3)} mL`}</div> <div className="text-white">{`${tmtvValue?.toFixed(3)} mL`}</div>
</div> </div>
) : null} ) : null}
</div> </div>

View File

@ -420,7 +420,13 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }:
return; return;
} }
return await workerManager.executeTask('suv-peak-worker', 'calculateTMTV', labelmapProps); const tmtv = await workerManager.executeTask(
'suv-peak-worker',
'calculateTMTV',
labelmapProps
);
return tmtv;
}, },
exportTMTVReportCSV: async ({ segmentations, tmtv, config, options }) => { exportTMTVReportCSV: async ({ segmentations, tmtv, config, options }) => {
const segReport = commandsManager.runCommand('getSegmentationCSVReport', { const segReport = commandsManager.runCommand('getSegmentationCSVReport', {

View File

@ -86,7 +86,7 @@ function modeFactory() {
initToolGroups(extensionManager, toolGroupService, commandsManager); initToolGroups(extensionManager, toolGroupService, commandsManager);
// init customizations // init customizations
customizationService.addModeCustomizations([ customizationService.setCustomizations([
'@ohif/extension-test.customizationModule.custom-context-menu', '@ohif/extension-test.customizationModule.custom-context-menu',
]); ]);

View File

@ -1,69 +0,0 @@
import React from 'react';
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuPortal,
DropdownMenuLabel,
Icons,
} from '@ohif/ui-next';
export const performCustomizations = customizationService => {
// Set the custom SegmentationTable
customizationService.addModeCustomizations([
// To disable editing in the SegmentationTable
{
id: 'PanelSegmentation.disableEditing',
disableEditing: true,
},
// to only show current study in the panel study browser
// {
// id: 'PanelStudyBrowser.studyMode',
// mode: 'primary',
// },
// To disable editing in the MeasurementTable
// {
// id: 'PanelMeasurement.disableEditing',
// disableEditing: true,
// },
// {
// id: 'measurementLabels',
// labelOnMeasure: true,
// exclusive: true,
// items: [
// { value: 'Head', label: 'Head' },
// { value: 'Shoulder', label: 'Shoulder' },
// { value: 'Knee', label: 'Knee' },
// { value: 'Toe', label: 'Toe' },
// ],
// },
/**
* Custom Dropdown Menu Item
*/
// {
// id: 'PanelSegmentation.CustomDropdownMenuContent',
// content: ({
// activeSegmentation,
// onSegmentationAdd,
// onSegmentationRemoveFromViewport,
// onSegmentationEdit,
// onSegmentationDelete,
// allowExport,
// storeSegmentation,
// onSegmentationDownload,
// onSegmentationDownloadRTSS,
// t,
// }) => (
// <DropdownMenuContent align="start">
// <DropdownMenuItem onClick={() => onSegmentationDelete(activeSegmentation.id)}>
// <Icons.Delete className="text-red-600" />
// <span className="pl-2 text-red-600">{t('My Custom Dropdown Menu Item')}</span>
// </DropdownMenuItem>
// </DropdownMenuContent>
// ),
// },
]);
};

View File

@ -4,7 +4,6 @@ import { id } from './id';
import initToolGroups from './initToolGroups'; import initToolGroups from './initToolGroups';
import toolbarButtons from './toolbarButtons'; import toolbarButtons from './toolbarButtons';
import moreTools from './moreTools'; import moreTools from './moreTools';
import { performCustomizations } from './customizations';
// Allow this mode by excluding non-imaging modalities such as SR, SEG // Allow this mode by excluding non-imaging modalities such as SR, SEG
// Also, SM is not a simple imaging modalities, so exclude it. // Also, SM is not a simple imaging modalities, so exclude it.
@ -89,15 +88,12 @@ function modeFactory({ modeConfiguration }) {
measurementService, measurementService,
toolbarService, toolbarService,
toolGroupService, toolGroupService,
customizationService,
panelService, panelService,
segmentationService, segmentationService,
} = servicesManager.services; } = servicesManager.services;
measurementService.clearMeasurements(); measurementService.clearMeasurements();
performCustomizations(customizationService);
// Init Default and SR ToolGroups // Init Default and SR ToolGroups
initToolGroups(extensionManager, toolGroupService, commandsManager, this.labelConfig); initToolGroups(extensionManager, toolGroupService, commandsManager, this.labelConfig);

View File

@ -62,36 +62,19 @@ function modeFactory({ modeConfiguration }) {
// the primary button section is created in the workflow steps // the primary button section is created in the workflow steps
// specific to the step // specific to the step
customizationService.addModeCustomizations([ customizationService.setCustomizations({
{ 'panelSegmentation.tableMode': {
id: 'PanelSegmentation.tableMode', $set: 'expanded',
mode: 'expanded',
}, },
{ 'panelSegmentation.onSegmentationAdd': {
id: 'PanelSegmentation.onSegmentationAdd', $set: () => {
onSegmentationAdd: () => {
commandsManager.run('createNewLabelMapForDynamicVolume'); commandsManager.run('createNewLabelMapForDynamicVolume');
}, },
}, },
{ 'panelSegmentation.showAddSegment': {
id: 'PanelSegmentation.showAddSegment', $set: false,
showAddSegment: false,
}, },
{ });
id: 'PanelSegmentation.readableText',
// remove following if you are not interested in that stats
readableText: {
lesionStats: 'Lesion Statistics',
minValue: 'Minimum Value',
maxValue: 'Maximum Value',
meanValue: 'Mean Value (ml)',
volume: 'Volume',
suvPeak: 'SUV Peak',
suvMax: 'Maximum SUV',
suvMaxIJK: 'SUV Max IJK',
},
},
]);
// Auto play the clip initially when the volumes are loaded // Auto play the clip initially when the volumes are loaded
const { unsubscribe } = cornerstoneViewportService.subscribe( const { unsubscribe } = cornerstoneViewportService.subscribe(

View File

@ -101,33 +101,16 @@ function modeFactory({ modeConfiguration }) {
'BrushTools', 'BrushTools',
]); ]);
customizationService.addModeCustomizations([ customizationService.setCustomizations({
{ 'panelSegmentation.tableMode': {
id: 'PanelSegmentation.tableMode', $set: 'expanded',
mode: 'expanded',
}, },
{ 'panelSegmentation.onSegmentationAdd': {
id: 'PanelSegmentation.onSegmentationAdd', $set: () => {
onSegmentationAdd: () => {
commandsManager.run('createNewLabelmapFromPT'); commandsManager.run('createNewLabelmapFromPT');
}, },
}, },
{ });
id: 'PanelSegmentation.readableText',
// remove following if you are not interested in that stats
readableText: {
lesionStats: 'Lesion Statistics',
minValue: 'Minimum Value',
maxValue: 'Maximum Value',
meanValue: 'Mean Value',
volume: 'Volume (ml)',
suvPeak: 'SUV Peak',
suvMax: 'Maximum SUV',
suvMaxIJK: 'SUV Max IJK',
lesionGlyoclysisStats: 'Lesion Glycolysis',
},
},
]);
// For the hanging protocol we need to decide on the window level // For the hanging protocol we need to decide on the window level
// based on whether the SUV is corrected or not, hence we can't hard // based on whether the SUV is corrected or not, hence we can't hard

View File

@ -15,52 +15,6 @@ window.config = {
customizationService: [ customizationService: [
'@ohif/extension-default.customizationModule.datasources', '@ohif/extension-default.customizationModule.datasources',
'@ohif/extension-default.customizationModule.helloPage', '@ohif/extension-default.customizationModule.helloPage',
{
id: '@ohif/cornerstoneOverlay',
// Append recursively, rather than replacing
merge: 'Append',
topRightItems: {
id: 'cornerstoneOverlayTopRight',
items: [
{
id: 'PatientNameOverlay',
// Note below that here we are using the customization prototype of
// `ohif.overlayItem` which was registered to the customization module in
// `ohif/extension-default` extension.
customizationType: 'ohif.overlayItem',
// the following props are passed to the `ohif.overlayItem` prototype
// which is used to render the overlay item based on the label, color,
// conditions, etc.
attribute: 'PatientName',
label: 'PN:',
title: 'Patient Name',
color: 'yellow',
condition: ({ instance }) => instance?.PatientName,
contentF: ({ instance, formatters: { formatPN } }) =>
formatPN(instance.PatientName) +
(instance.PatientSex ? ' (' + instance.PatientSex + ')' : ''),
},
],
},
topLeftItems: {
items: {
// Note the -10000 means -10000 + length of existing list, which is
// much before the start of hte list, so put the new value at the start.
'-10000': {
id: 'Species',
customizationType: 'ohif.overlayItem',
label: 'Species:',
color: 'red',
background: 'green',
condition: ({ instance }) => instance?.PatientSpeciesDescription,
contentF: ({ instance }) =>
instance.PatientSpeciesDescription + '/' + instance.PatientBreedDescription,
},
},
},
},
], ],
defaultDataSourceName: 'e2e', defaultDataSourceName: 'e2e',

View File

@ -1,10 +1,8 @@
import getStudies from './studiesList'; import getStudies from './studiesList';
import { DicomMetadataStore, log } from '@ohif/core'; import { DicomMetadataStore, log, utils, Enums } from '@ohif/core';
import isSeriesFilterUsed from '../../utils/isSeriesFilterUsed'; import isSeriesFilterUsed from '../../utils/isSeriesFilterUsed';
import { utils, Enums } from '@ohif/core'; const { getSplitParam } = utils;
const { sortingCriteria, getSplitParam } = utils;
/** /**
* Initialize the route. * Initialize the route.
@ -85,9 +83,7 @@ export async function defaultRouteInit(
StudyInstanceUID, StudyInstanceUID,
filters, filters,
returnPromises: true, returnPromises: true,
sortCriteria: sortCriteria: customizationService.getCustomization('sortingCriteria'),
customizationService.get('sortingCriteria') ||
sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria,
}) })
); );

View File

@ -525,8 +525,7 @@ function WorkList({
} }
const { customizationService } = servicesManager.services; const { customizationService } = servicesManager.services;
const { component: DicomUploadComponent } = const DicomUploadComponent = customizationService.getCustomization('dicomUploadComponent');
customizationService.getCustomization('dicomUploadComponent') || {};
const uploadProps = const uploadProps =
DicomUploadComponent && dataSource.getConfig()?.dicomUploadEnabled DicomUploadComponent && dataSource.getConfig()?.dicomUploadEnabled
@ -554,8 +553,9 @@ function WorkList({
} }
: undefined; : undefined;
const { component: dataSourceConfigurationComponent } = const dataSourceConfigurationComponent = customizationService.getCustomization(
customizationService.get('ohif.dataSourceConfigurationComponent') ?? {}; 'ohif.dataSourceConfigurationComponent'
);
return ( return (
<div className="flex h-screen flex-col bg-black"> <div className="flex h-screen flex-col bg-black">

View File

@ -108,7 +108,8 @@ const createRoutes = ({
props: { children: WorkList, servicesManager, extensionManager }, props: { children: WorkList, servicesManager, extensionManager },
}; };
const customRoutes = customizationService.getGlobalCustomization('customRoutes'); const customRoutes = customizationService.getCustomization('routes.customRoutes');
const allRoutes = [ const allRoutes = [
...routes, ...routes,
...(showStudyList ? [WorkListRoute] : []), ...(showStudyList ? [WorkListRoute] : []),

View File

@ -48,6 +48,7 @@
"dicomweb-client": "^0.10.4", "dicomweb-client": "^0.10.4",
"gl-matrix": "^3.4.3", "gl-matrix": "^3.4.3",
"isomorphic-base64": "^1.0.2", "isomorphic-base64": "^1.0.2",
"immutability-helper": "^3.1.1",
"lodash.clonedeep": "^4.5.0", "lodash.clonedeep": "^4.5.0",
"lodash.merge": "^4.6.2", "lodash.merge": "^4.6.2",
"lodash.mergewith": "^4.6.2", "lodash.mergewith": "^4.6.2",

View File

@ -94,17 +94,30 @@ export class CommandsManager {
* @param {CommandDefinition} definition - {@link CommandDefinition} * @param {CommandDefinition} definition - {@link CommandDefinition}
*/ */
registerCommand(contextName, commandName, definition) { registerCommand(contextName, commandName, definition) {
if (typeof definition !== 'object') { if (typeof definition !== 'object' && typeof definition !== 'function') {
return; return;
} }
// Validate and restrict keys to prevent prototype pollution
const isSafeKey = key => {
return key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
};
if (!isSafeKey(contextName) || !isSafeKey(commandName)) {
throw new Error('Invalid key name to prevent prototype pollution');
}
const context = this.getContext(contextName); const context = this.getContext(contextName);
if (!context) { if (!context) {
return; return;
} }
if (typeof definition === 'function') {
context[commandName] = { commandFn: definition, options: {} };
} else {
context[commandName] = definition; context[commandName] = definition;
} }
}
/** /**
* Finds a command with the provided name if it exists in the specified context, * Finds a command with the provided name if it exists in the specified context,
@ -138,6 +151,11 @@ export class CommandsManager {
* @param {String} [contextName] * @param {String} [contextName]
*/ */
public runCommand(commandName: string, options = {}, contextName?: string | string[]) { public runCommand(commandName: string, options = {}, contextName?: string | string[]) {
if (typeof commandName === 'function') {
// If commandName is a function, run it directly
return commandName(options);
}
const definition = this.getCommand(commandName, contextName); const definition = this.getCommand(commandName, contextName);
if (!definition) { if (!definition) {
log.warn(`Command "${commandName}" not found in current context`); log.warn(`Command "${commandName}" not found in current context`);
@ -147,7 +165,7 @@ export class CommandsManager {
const { commandFn } = definition; const { commandFn } = definition;
const commandParams = Object.assign( const commandParams = Object.assign(
{}, {},
definition.options, // "Command configuration" definition.options || {}, // "Command configuration"
options // "Time of call" info options // "Time of call" info
); );
@ -159,13 +177,16 @@ export class CommandsManager {
} }
} }
public static convertCommands(toRun: Command | Commands | Command[] | string) { public static convertCommands(toRun: Command | Commands | Command[] | string | Function) {
if (typeof toRun === 'string') { if (typeof toRun === 'string') {
return [{ commandName: toRun }]; return [{ commandName: toRun }];
} }
if ('commandName' in toRun) { if ('commandName' in toRun) {
return [toRun as ComplexCommand]; return [toRun as ComplexCommand];
} }
if (typeof toRun === 'function') {
return [{ commandName: toRun }];
}
if ('commands' in toRun) { if ('commands' in toRun) {
const commandsInput = (toRun as Commands).commands; const commandsInput = (toRun as Commands).commands;
return this.convertCommands(commandsInput); return this.convertCommands(commandsInput);

View File

@ -1,313 +1,497 @@
import CustomizationService, { CustomizationType, MergeEnum } from './CustomizationService'; // File: CustomizationService.registrationAndOperations.test.js
import log from '../../log'; import CustomizationService, { CustomizationScope } from './CustomizationService';
jest.mock('../../log.js', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}));
const commandsManager = {};
const extensionManager = { const extensionManager = {
registeredExtensionIds: [], registeredExtensionIds: [],
moduleEntries: {}, moduleEntries: {},
getRegisteredExtensionIds: () => extensionManager.registeredExtensionIds, getRegisteredExtensionIds: () => extensionManager.registeredExtensionIds,
getModuleEntry: function (id) { getModuleEntry: function (id) {
return this.moduleEntries[id]; return this.moduleEntries[id];
}, },
}; };
const commandsManager = {}; const noop = () => {};
const ohifOverlayItem = { // A helper default customization module that mimics the structure returned by the module.
id: 'ohif.overlayItem', function getDefaultCustomizationModule() {
content: function (props) {
return { return {
label: this.label, // Simple types
value: props[this.attribute], showAddSegment: true,
ver: 'default', somethingFalse: false,
}; onAddSegment: () => 'default add',
// Array of primitives
NumbersList: [1, 2, 3, 4],
// Object
SeriesInfo: {
label: 'Series Date',
sortFunction: noop,
views: ['sagittal', 'coronal', 'axial'],
advanced: {
subKey: 'original',
anotherKey: 42,
},
},
// Array of objects
studyBrowser: [
{
id: 'seriesDate',
label: 'Series Date',
sortFunction: noop,
},
],
advanced: {
firstLabel: 'hello',
functions: [
{
id: 'seriesDate',
label: 'Series Date',
sortFunction: () => {},
viewFunctions: [
{ id: 'sagittal', label: 'Sagittal', sortFunction: () => {} },
{ id: 'coronal', label: 'Coronal', sortFunction: () => {} },
{ id: 'axial', label: 'Axial', sortFunction: () => {} },
],
},
],
}, },
}; };
}
const testItem = { describe('CustomizationService - Registration + API Operations', () => {
id: 'testItem',
customizationType: 'ohif.overlayItem',
attribute: 'testAttribute',
label: 'testItemLabel',
};
describe('CustomizationService.ts', () => {
let customizationService; let customizationService;
let configuration;
beforeEach(() => { beforeEach(() => {
log.warn.mockClear(); customizationService = new CustomizationService({ commandsManager, configuration: {} });
jest.clearAllMocks();
configuration = {}; // Simulate default registrations.
customizationService = new CustomizationService({ customizationService.addReferences(getDefaultCustomizationModule(), CustomizationScope.Default);
configuration,
commandsManager,
});
extensionManager.registeredExtensionIds = [];
extensionManager.moduleEntries = {};
}); });
describe('init', () => { afterEach(() => {
it('init succeeds', () => { customizationService.onModeExit();
customizationService.init(extensionManager);
}); });
it('configurationRegistered', () => { // Check that defaults are registered
configuration.testItem = testItem; it('has registered default customizations', () => {
customizationService.init(extensionManager); const defaultShowAddSegment = customizationService.getCustomization('showAddSegment');
expect(customizationService.getGlobalCustomization('testItem')).toBe(testItem); expect(defaultShowAddSegment).toBe(true);
const defaultNumbersList = customizationService.getCustomization('NumbersList');
expect(defaultNumbersList).toEqual([1, 2, 3, 4]);
const defaultSeriesInfo = customizationService.getCustomization('SeriesInfo');
expect(defaultSeriesInfo.label).toBe('Series Date');
expect(defaultSeriesInfo.advanced.subKey).toBe('original');
const defaultStudyBrowser = customizationService.getCustomization('studyBrowser');
expect(Array.isArray(defaultStudyBrowser)).toBe(true);
expect(defaultStudyBrowser.length).toBe(1);
//
const advanced = customizationService.getCustomization('advanced');
expect(advanced.firstLabel).toBe('hello');
expect(advanced.functions.length).toBe(1);
expect(advanced.functions[0].id).toBe('seriesDate');
expect(advanced.functions[0].viewFunctions.length).toBe(3);
expect(advanced.functions[0].viewFunctions[0].id).toBe('sagittal');
expect(advanced.functions[0].viewFunctions[1].id).toBe('coronal');
expect(advanced.functions[0].viewFunctions[2].id).toBe('axial');
}); });
it('defaultRegistered', () => { // 1. Simple Data Types
extensionManager.registeredExtensionIds.push('@testExtension'); describe('Simple Data Types', () => {
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = { it('replaces boolean value using $set over the default', () => {
name: 'default', // Update the default value with a new one using $set.
value: [testItem], customizationService.setCustomizations({
}; showAddSegment: { $set: false },
customizationService.init(extensionManager); });
expect(customizationService.getGlobalCustomization('testItem')).toBe(testItem); const result = customizationService.getCustomization('showAddSegment');
// Mode/global should override the default.
expect(result).toBe(false);
});
it('replaces boolean value using $set over the default false', () => {
// Update the default value with a new one using $set.
customizationService.setCustomizations({
somethingFalse: { $set: true },
});
const result = customizationService.getCustomization('somethingFalse');
// Mode/global should override the default.
expect(result).toBe(true);
});
it('replaces function value using $set over the default', () => {
// Original default returns "default add"
const original = customizationService.getCustomization('onAddSegment');
expect(original()).toBe('default add');
// Now update the function
customizationService.setCustomizations({
onAddSegment: { $set: () => 999 },
});
const updated = customizationService.getCustomization('onAddSegment');
expect(updated()).toBe(999);
});
it('replaces two properties at once', () => {
// Original default returns "default add"
const original = customizationService.getCustomization('onAddSegment');
expect(original()).toBe('default add');
// Now update the function
customizationService.setCustomizations({
onAddSegment: { $set: () => 998 },
showAddSegment: { $set: false },
});
expect(customizationService.getCustomization('onAddSegment')).toBeDefined();
expect(customizationService.getCustomization('showAddSegment')).toBe(false);
expect(customizationService.getCustomization('onAddSegment')()).toBe(998);
}); });
}); });
describe('customizationType', () => { // 2. Arrays of Primitives
it('inherits type', () => { describe('Arrays of Primitives', () => {
extensionManager.registeredExtensionIds.push('@testExtension'); it('replaces entire array with $set over default', () => {
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = { customizationService.setCustomizations({
name: 'default', NumbersList: { $set: [5, 6, 7, 8, 9] },
value: [ohifOverlayItem], });
}; const result = customizationService.getCustomization('NumbersList');
configuration.testItem = testItem; expect(result).toEqual([5, 6, 7, 8, 9]);
customizationService.init(extensionManager);
const item = customizationService.getGlobalCustomization('testItem');
const props = { testAttribute: 'testAttrValue' };
const result = item.content(props);
expect(result.label).toBe(testItem.label);
expect(result.value).toBe(props.testAttribute);
expect(result.ver).toBe('default');
}); });
it('inline default inherits type', () => { it('applies $push, $unshift, and $splice to default array', () => {
extensionManager.registeredExtensionIds.push('@testExtension'); // Update array using merge commands
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = { customizationService.setCustomizations({
name: 'default', NumbersList: {
value: [ohifOverlayItem], $push: [5, 6],
};
configuration.testItem = testItem;
customizationService.init(extensionManager);
const item = customizationService.getCustomization('testItem2', {
id: 'testItem2',
customizationType: 'ohif.overlayItem',
label: 'otherLabel',
attribute: 'otherAttr',
});
// Customizes the default value, as this is testItem2
const props = { otherAttr: 'other attribute value' };
const result = item.content(props);
expect(result.label).toBe('otherLabel');
expect(result.value).toBe(props.otherAttr);
expect(result.ver).toBe('default');
});
});
describe('mode customization', () => {
it('onModeEnter can add extensions', () => {
extensionManager.registeredExtensionIds.push('@testExtension');
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
name: 'default',
value: [ohifOverlayItem],
};
customizationService.init(extensionManager);
expect(customizationService.getModeCustomization('testItem')).toBeUndefined();
customizationService.addModeCustomizations([testItem]);
expect(customizationService.getGlobalCustomization('testItem')).toBeUndefined();
const item = customizationService.getModeCustomization('testItem');
expect(item).not.toBeUndefined();
const props = { testAttribute: 'testAttrValue' };
const result = item.content(props);
expect(result.label).toBe(testItem.label);
expect(result.value).toBe(props.testAttribute);
expect(result.ver).toBe('default');
});
it('global customizations override modes', () => {
extensionManager.registeredExtensionIds.push('@testExtension');
extensionManager.moduleEntries['@testExtension.customizationModule.global'] = {
name: 'default',
value: [ohifOverlayItem],
};
configuration.testItem = testItem;
customizationService.init(extensionManager);
// Add a mode customization that would otherwise fail below
customizationService.addModeCustomizations([{ ...testItem, label: 'other' }]);
const item = customizationService.getModeCustomization('testItem');
const props = { testAttribute: 'testAttrValue' };
const result = item.content(props);
expect(result.label).toBe(testItem.label);
expect(result.value).toBe(props.testAttribute);
});
it('mode customizations override default', () => {
extensionManager.registeredExtensionIds.push('@testExtension');
extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
name: 'default',
value: [ohifOverlayItem, testItem],
};
customizationService.init(extensionManager);
// Add a mode customization that would otherwise fail below
customizationService.addModeCustomizations([{ ...testItem, label: 'other' }]);
const item = customizationService.getCustomization('testItem');
const props = { testAttribute: 'testAttrValue' };
const result = item.content(props);
expect(result.label).toBe('other');
expect(result.value).toBe(props.testAttribute);
});
});
describe('merge', () => {
it('appends to global configuration', () => {
customizationService.init(extensionManager);
customizationService.setGlobalCustomization('appendSet', {
values: [{ id: 'one' }, { id: 'two' }],
});
const appendSet = customizationService.getCustomization('appendSet');
expect(appendSet.values.length).toBe(2);
customizationService.setGlobalCustomization(
'appendSet',
{
values: [{ id: 'three' }],
}, },
MergeEnum.Append });
); const result = customizationService.getCustomization('NumbersList');
const appendSet2 = customizationService.getCustomization('appendSet'); expect(result).toEqual([1, 2, 3, 4, 5, 6]);
expect(appendSet2.values.length).toBe(3);
}); });
it('appends mode to default without touching default', () => { it('applies $push, $unshift, and $splice to default array', () => {
customizationService.init(extensionManager); // Update array using merge commands
customizationService.setCustomizations({
customizationService.setDefaultCustomization('appendSet', { NumbersList: {
values: [{ id: 'one' }, { id: 'two' }], $unshift: [0],
});
const appendSet = customizationService.get('appendSet');
expect(appendSet.values.length).toBe(2);
customizationService.setModeCustomization(
'appendSet',
{
values: [{ id: 'three' }],
}, },
MergeEnum.Append });
); const result = customizationService.getCustomization('NumbersList');
expect(result).toEqual([0, 1, 2, 3, 4]);
expect(appendSet.values.length).toBe(2);
const appendSet2 = customizationService.getModeCustomization('appendSet');
expect(appendSet2.values.length).toBe(3);
}); });
it('merges values by name/position', () => { it('applies $push, $unshift, and $splice to default array', () => {
customizationService.init(extensionManager); // Update array using merge commands
customizationService.setCustomizations({
customizationService.setDefaultCustomization('appendSet', { NumbersList: {
values: [{ id: 'one', obj: { v: '5' }, list: [1, 2, 3] }, { id: 'two' }], $splice: [
}); [2, 1, 99], // At index 2, remove
const appendSet = customizationService.get('appendSet'); ],
expect(appendSet.values.length).toBe(2);
customizationService.setModeCustomization(
'appendSet',
{
values: [{ id: 'three', obj: { v: 2 }, list: [3, 2, 1, 4] }],
}, },
MergeEnum.Merge, });
); const result = customizationService.getCustomization('NumbersList');
expect(result).toEqual([1, 2, 99, 4]);
const appendSet2 = customizationService.get('appendSet');
const [value0] = appendSet2.values;
expect(value0.id).toBe('three');
expect(value0.list).toEqual([3, 2, 1, 4]);
}); });
it('merges functions', () => { it('applies $push, $unshift, and $splice to default array', () => {
customizationService.init(extensionManager); // Update array using merge commands
customizationService.setCustomizations({
customizationService.setDefaultCustomization('appendSet', { NumbersList: {
values: [{ f: () => 0, id: '0' }, { f: () => 5, id: '5' }], $push: [5, 6],
}); $unshift: [0],
const appendSet = customizationService.get('appendSet');
expect(appendSet.values.length).toBe(2);
customizationService.setModeCustomization(
'appendSet',
{
values: [{ f: () => 2, id: '2' }]
}, },
MergeEnum.Merge, });
); const result = customizationService.getCustomization('NumbersList');
const appendSet2 = customizationService.get('appendSet'); expect(result).toEqual([0, 1, 2, 3, 4, 5, 6]);
const [value0, value1] = appendSet2.values; });
expect(value0.f()).toBe(2);
expect(value1.f()).toBe(5);
}); });
it('merges list with object', () => { // 3. Objects
customizationService.init(extensionManager); describe('Objects', () => {
it('replaces entire object with $set', () => {
customizationService.setCustomizations({
SeriesInfo: {
$set: {
label: 'Series Number',
sortFunction: (a, b) => a?.SeriesNumber - b?.SeriesNumber,
views: ['3D'],
},
},
});
const result = customizationService.getCustomization('SeriesInfo');
const destination = [ expect(result.label).toBe('Series Number');
expect(result.sortFunction).not.toEqual(noop);
expect(result.views).toEqual(['3D']);
});
it('merges object fields with $merge over default', () => {
// Merge basic fields (in mode should override defaults)
customizationService.setCustomizations({
SeriesInfo: {
$merge: {
label: 'New Label',
extraField: true,
},
},
});
let result = customizationService.getCustomization('SeriesInfo');
expect(result.label).toBe('New Label');
expect(result.extraField).toBe(true);
// Merge deeper nested fields on the "advanced" property.
customizationService.setCustomizations({
SeriesInfo: {
advanced: {
$merge: {
subKey: 'updatedSubValue',
newSubKey: 123,
},
},
},
});
result = customizationService.getCustomization('SeriesInfo');
expect(result.advanced.subKey).toBe('updatedSubValue');
expect(result.advanced.newSubKey).toBe(123);
expect(result.advanced.anotherKey).toBe(42);
});
it('applies a function to modify a property with $apply', () => {
customizationService.setCustomizations({
SeriesInfo: {
$apply: oldValue => ({
...oldValue,
label: 'Series Number (via $apply)',
}),
},
});
const result = customizationService.getCustomization('SeriesInfo');
expect(result.label).toBe('Series Number (via $apply)');
});
});
// 4. Arrays of Objects
describe('Arrays of Objects', () => {
it('replaces entire array of objects using $set', () => {
customizationService.setCustomizations({
studyBrowser: {
$set: [
{
id: 'seriesNumber',
label: 'Series Number',
sortFunction: (a, b) => a?.SeriesNumber - b?.SeriesNumber,
},
{
id: 'seriesDate',
label: 'Series Date',
sortFunction: (a, b) => new Date(b?.SeriesDate) - new Date(a?.SeriesDate),
},
],
},
});
const result = customizationService.getCustomization('studyBrowser');
expect(result.length).toBe(2);
expect(result[0].label).toBe('Series Number');
expect(result[1].label).toBe('Series Date');
});
it('updates array of objects with $push and $splice', () => {
// Append a new item using $push
customizationService.setCustomizations({
studyBrowser: {
$push: [
{
id: 'seriesNumber',
label: 'Series Number',
sortFunction: (a, b) => a?.SeriesNumber - b?.SeriesNumber,
},
],
},
});
let result = customizationService.getCustomization('studyBrowser');
expect(result.length).toBe(2);
expect(result[0].label).toBe('Series Date');
expect(result[1].label).toBe('Series Number');
// Insert at index 1 with $splice
customizationService.setCustomizations({
studyBrowser: {
$splice: [
[
1, 1,
{ id: 'two', value: 2, list: [5, 6], }, 0,
{ id: 'three', value: 3 } {
]; id: 'anotherItem',
label: 'Another Item',
const source = { sortFunction: noop,
two: { value: 'updated2', list: { 0: 8 } },
1: { extraValue: 2, list: [7], },
1.0001: { id: 'inserted', value: 1.0001 },
'-1': {
value: -3
}, },
],
],
},
});
result = customizationService.getCustomization('studyBrowser');
expect(result.length).toBe(3);
expect(result[0].label).toBe('Series Date');
expect(result[1].label).toBe('Another Item');
});
});
// 5. Advanced Nested Structures
describe('Advanced Nested Structures', () => {
it('updates first level properties in advanced object', () => {
customizationService.setCustomizations({
advanced: {
firstLabel: {
$set: 'newLabel',
},
},
});
const result = customizationService.getCustomization('advanced');
expect(result.firstLabel).toBe('newLabel');
expect(result.functions).toBeDefined();
});
it('updates nested objects within functions array using $filter and $merge', () => {
customizationService.setCustomizations({
advanced: {
// filter an object that is inside the advanced object
// and then merge the object
$filter: {
match: { id: 'seriesDate' },
$merge: {
label: 'Series Data (via $filter)',
},
},
},
});
const result = customizationService.getCustomization('advanced');
expect(result.functions.length).toBe(1);
expect(result.functions[0].label).toBe('Series Data (via $filter)');
});
it('updates deeply nested view functions using $filter', () => {
customizationService.setCustomizations({
advanced: {
$filter: {
match: { id: 'axial' },
$merge: {
label: 'Axial (via $filter)',
},
},
},
});
const result = customizationService.getCustomization('advanced');
expect(result.functions.length).toBe(1);
expect(result.functions[0].viewFunctions[2].label).toBe('Axial (via $filter)');
});
});
// 6. Multiple Default Registrations
describe('Multiple Default Registrations', () => {
it('allows subsequent default registrations to enhance previous ones', () => {
customizationService = new CustomizationService({ commandsManager, configuration: {} });
// First extension registers its defaults
const firstExtensionDefaults = {
simpleList: [1, 2, 3],
}; };
customizationService.addReferences(firstExtensionDefaults, CustomizationScope.Default);
customizationService.setDefaultCustomization('appendSet', { // Second extension enhances the first one's defaults
values: destination, const secondExtensionDefaults = {
}); simpleList: { $push: [4, 5] },
customizationService.setModeCustomization('appendSet', { };
values: source, customizationService.addReferences(secondExtensionDefaults, CustomizationScope.Default);
}, MergeEnum.Append);
const { values } = customizationService.getCustomization('appendSet'); // Verify the final state combines both extensions' contributions
const [zero, one, two, three] = values; const result = customizationService.getCustomization('simpleList');
expect(zero).toBe(1); expect(result).toEqual([1, 2, 3, 4, 5]);
expect(one.value).toBe('updated2'); });
expect(one.extraValue).toBe(2); });
expect(one.list).toEqual([8, 6, 7]);
expect(two.id).toBe('inserted'); describe('CustomizationService - Inheritance (`inheritsFrom`)', () => {
expect(three.value).toBe(-3); it('inherits properties from the parent customization', () => {
// Register a parent customization
customizationService.setCustomizations(
{
'test.overlayItem': {
label: 'Default Label',
color: 'blue',
},
},
CustomizationScope.Default
);
// Register a child customization with `inheritsFrom`
customizationService.setCustomizations({
'viewportOverlay.topLeft.StudyDate': {
$set: {
inheritsFrom: 'test.overlayItem',
label: 'Study Date',
title: ' date',
},
},
});
const customization = customizationService.getCustomization(
'viewportOverlay.topLeft.StudyDate'
);
// Check that the inherited and overridden properties exist
expect(customization.label).toBe('Study Date'); // Overridden
expect(customization.color).toBe('blue'); // Inherited
});
it('executes transform methods from the parent customization', () => {
// Register a parent customization
customizationService.setCustomizations(
{
'test.overlayItem': {
$set: {
$transform: function () {
return {
label: this.label,
additionalKey: 'transformedValue',
};
},
},
},
},
CustomizationScope.Default
);
// Register a child customization with `inheritsFrom`
customizationService.setCustomizations({
'viewportOverlay.bottomRight.InstanceNumber': {
$set: {
inheritsFrom: 'test.overlayItem',
label: 'Instance Number',
title: 'Instance Title',
},
},
});
const customization = customizationService.getCustomization(
'viewportOverlay.bottomRight.InstanceNumber'
);
// Verify that the transform function from the parent is executed
expect(customization.additionalKey).toBe('transformedValue');
expect(customization.label).toBe('Instance Number');
expect(customization.title).toBe(undefined);
}); });
}); });
}); });

View File

@ -1,54 +1,36 @@
import { mergeWith, cloneDeepWith } from 'lodash'; import update, { extend } from 'immutability-helper';
import { PubSubService } from '../_shared/pubSubServiceInterface'; import { PubSubService } from '../_shared/pubSubServiceInterface';
import type { Customization, NestedStrings } from './types'; import type { Customization } from './types';
import type { CommandsManager } from '../../classes'; import type { CommandsManager } from '../../classes';
import type { ExtensionManager } from '../../extensions'; import type { ExtensionManager } from '../../extensions';
const EVENTS = { const EVENTS = {
MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified', MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified',
GLOBAL_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:globalModified', GLOBAL_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:globalModified',
DEFAULT_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:defaultModified',
}; };
const flattenNestedStrings = ( /**
strs: NestedStrings | string, * Enum representing the different scopes of customizations available in the system.
ret?: Record<string, string> */
): Record<string, string> => { export enum CustomizationScope {
if (!ret) { /**
ret = {}; * Global customizations that override both mode and default customizations.
} * These are applied universally across the application.
if (!strs) { */
return ret; Global = 'global',
}
if (Array.isArray(strs)) {
for (const val of strs) {
flattenNestedStrings(val, ret);
}
} else {
ret[strs] = strs;
}
return ret;
};
export enum MergeEnum {
/** /**
* Append values in the nested arrays * Mode-specific customizations that are only active during a particular mode.
* These are cleared and reset when switching between modes.
*/ */
Append = 'Append', Mode = 'mode',
/**
* Merge values, replacing arrays
*/
Merge = 'Merge',
/**
* Replace the given value - this is the default
*/
Replace = 'Replace',
}
export enum CustomizationType { /**
Global = 'Global', * Default customizations that serve as fallbacks when no global or mode-specific
Mode = 'Mode', * customizations are defined. These can only be defined once.
Default = 'Default', */
Default = 'default',
} }
/** /**
@ -78,9 +60,12 @@ export enum CustomizationType {
* every module for the given id and to load it/add it to the extensions. * every module for the given id and to load it/add it to the extensions.
*/ */
export default class CustomizationService extends PubSubService { export default class CustomizationService extends PubSubService {
public static EVENTS = EVENTS;
public Scope = CustomizationScope;
public static REGISTRATION = { public static REGISTRATION = {
name: 'customizationService', name: 'customizationService',
create: ({ configuration = {}, commandsManager }) => { create: ({ configuration, commandsManager }) => {
return new CustomizationService({ configuration, commandsManager }); return new CustomizationService({ configuration, commandsManager });
}, },
}; };
@ -89,22 +74,25 @@ export default class CustomizationService extends PubSubService {
extensionManager: ExtensionManager; extensionManager: ExtensionManager;
/** /**
* mode customizations are changes to the default behaviour which are reset * A collection of global customizations that act as a priority layer.
* every time a new mode is entered. This allows the mode to define custom * These customizations are applied universally, overriding both mode-specific
* behaviour, and not interfere with other modes. * and default customizations. Ideal for system-wide changes.
*/
private modeCustomizations = new Map<string, Customization>();
/**
* global customizations, are customizations which are set as a global default
* This allows changes across the board to be applied, essentially as a priority
* setting.
*/ */
private globalCustomizations = new Map<string, Customization>(); private globalCustomizations = new Map<string, Customization>();
/** /**
* Default customizations allow applying default values. The intent is that * A collection of mode-specific customizations. These allow modes to define
* there is only one customization of that type, and it is registered at setup * their own behavior without impacting other modes. These customizations
* time. * are cleared and redefined whenever a mode changes, ensuring isolation
* between modes. Read more about modes in the modes documentation.
*/
private modeCustomizations = new Map<string, Customization>();
/**
* A collection of default customizations used as fallbacks. These serve as
* the base configuration and are registered at setup. Default customizations
* provide baseline values that can be overridden by mode or global customizations.
* Use these for cases where default values are necessary for predictable behavior.
*/ */
private defaultCustomizations = new Map<string, Customization>(); private defaultCustomizations = new Map<string, Customization>();
@ -113,13 +101,12 @@ export default class CustomizationService extends PubSubService {
* transform every time a customization is requested. * transform every time a customization is requested.
*/ */
private transformedCustomizations = new Map<string, Customization>(); private transformedCustomizations = new Map<string, Customization>();
private configuration: AppTypes.Config;
configuration: any;
constructor({ configuration, commandsManager }) { constructor({ configuration, commandsManager }) {
super(EVENTS); super(EVENTS);
this.configuration = configuration;
this.commandsManager = commandsManager; this.commandsManager = commandsManager;
this.configuration = configuration || {};
} }
public init(extensionManager: ExtensionManager): void { public init(extensionManager: ExtensionManager): void {
@ -128,33 +115,36 @@ export default class CustomizationService extends PubSubService {
this.defaultCustomizations.clear(); this.defaultCustomizations.clear();
// Clear modes because those are defined in onModeEnter functions. // Clear modes because those are defined in onModeEnter functions.
this.modeCustomizations.clear(); this.modeCustomizations.clear();
this.initDefaults();
this.extensionManager.getRegisteredExtensionIds().forEach(extensionId => {
const keyDefault = `${extensionId}.customizationModule.default`;
const defaultCustomizations = this._findExtensionValue(keyDefault);
if (defaultCustomizations) {
const { value } = defaultCustomizations;
this._addReference(value, CustomizationScope.Default);
}
const keyGlobal = `${extensionId}.customizationModule.global`;
const globalCustomizations = this._findExtensionValue(keyGlobal);
if (globalCustomizations) {
const { value } = globalCustomizations;
this._addReference(value, CustomizationScope.Global);
}
});
this.addReferences(this.configuration); this.addReferences(this.configuration);
} }
initDefaults(): void {
this.extensionManager.getRegisteredExtensionIds().forEach(extensionId => {
const keyDefault = `${extensionId}.customizationModule.default`;
const defaultCustomizations = this.findExtensionValue(keyDefault);
if (defaultCustomizations) {
const { value } = defaultCustomizations;
this.addReference(value, CustomizationType.Default);
}
const keyGlobal = `${extensionId}.customizationModule.global`;
const globalCustomizations = this.findExtensionValue(keyGlobal);
if (globalCustomizations) {
const { value } = globalCustomizations;
this.addReference(value, CustomizationType.Global);
}
});
}
findExtensionValue(value: string) {
const entry = this.extensionManager.getModuleEntry(value);
return entry as { value: Customization };
}
public onModeEnter(): void { public onModeEnter(): void {
this.clearTransformedCustomizations();
this.init(this.extensionManager);
}
public onModeExit(): void {
this.clearTransformedCustomizations();
}
private clearTransformedCustomizations(): void {
super.reset(); super.reset();
const modeCustomizationKeys = Array.from(this.modeCustomizations.keys()); const modeCustomizationKeys = Array.from(this.modeCustomizations.keys());
@ -165,65 +155,19 @@ export default class CustomizationService extends PubSubService {
this.modeCustomizations.clear(); this.modeCustomizations.clear();
} }
public onModeExit(): void {
this.onModeEnter();
}
public getModeCustomizations(): Map<string, Customization> {
return this.modeCustomizations;
}
public setModeCustomization(
customizationId: string,
customization: Customization,
merge = MergeEnum.Merge
): void {
const defaultCustomization = this.defaultCustomizations.get(customizationId);
const modeCustomization = this.modeCustomizations.get(customizationId);
const globCustomization = this.globalCustomizations.get(customizationId);
const sourceCustomization =
modeCustomization ||
(globCustomization && cloneDeepWith(globCustomization, cloneCustomizer)) ||
defaultCustomization ||
{};
// use the source merge type if not provided then fallback to merge
this.modeCustomizations.set(
customizationId,
this.mergeValue(sourceCustomization, customization, sourceCustomization.merge ?? merge)
);
this.transformedCustomizations.clear();
this._broadcastEvent(this.EVENTS.CUSTOMIZATION_MODIFIED, {
buttons: this.modeCustomizations,
button: this.modeCustomizations.get(customizationId),
});
}
/** /**
* This is the preferred getter for all customizations, * Unified getter for customizations.
* getting global (priority) customizations first,
* then mode customizations, and finally the default customization.
* *
* @param customizationId - the customization id to look for * @param customizationId - The ID of the customization to retrieve.
* @param defaultValue - is the default value to return. * @param scope - (Optional) The scope to retrieve from: 'global', 'mode', or 'default'.
* This value will be assigned as the default customization if there isn't * If not specified, it retrieves based on priority: global > mode > default.
* currently a default customization, and thus, the first default provided * @returns The requested customization.
* will be used as the default - you cannot update this after or have it depend
* on changing values.
* Also, the value returned by the get customization has merges/updates applied,
* and is thus may be modified from the value provided, and may not be the original
* default provided. This allows applying the defaults for things like inheritance.
* @return A customization to use if one is found, or the default customization,
* both enhanced with any customizationType inheritance (see transform)
*/ */
public getCustomization(customizationId: string, defaultValue?: Customization): Customization { public getCustomization(customizationId: string): Customization {
const transformed = this.transformedCustomizations.get(customizationId); const transformed = this.transformedCustomizations.get(customizationId);
if (transformed) { if (transformed) {
return transformed; return transformed;
} }
if (defaultValue && !this.defaultCustomizations.has(customizationId)) {
this.setDefaultCustomization(customizationId, defaultValue);
}
const customization = const customization =
this.globalCustomizations.get(customizationId) ?? this.globalCustomizations.get(customizationId) ??
this.modeCustomizations.get(customizationId) ?? this.modeCustomizations.get(customizationId) ??
@ -235,197 +179,254 @@ export default class CustomizationService extends PubSubService {
return newTransformed; return newTransformed;
} }
/** Mode customizations are changes to the behavior of the extensions /**
* when running in a given mode. Reset clears mode customizations. * Takes an object with multiple properties, each property containing
* immutability-helper commands, and applies them one by one.
* *
* Note that global customizations over-ride mode customizations * Example:
* customizationService.setCustomizations({
* showAddSegment: { $set: false },
* NumbersList: { $push: [99] },
* }, CustomizationScope.Mode)
* *
* @param defaultValue to return if no customization specified. * Or you can simply apply a list of strings that are customization module items in the
* extension.
*
* Example:
* customizationService.setCustomizations(['@ohif/extension-cornerstone-dicom-seg.customizationModule.dicom-seg-sorts'], CustomizationScope.Mode)
*/ */
public getModeCustomization = this.getCustomization; public setCustomizations(
customizations: string[] | Record<string, Customization>,
scope: CustomizationScope = CustomizationScope.Mode
): void {
if (Array.isArray(customizations)) {
customizations.forEach(customization => {
this._addReference(customization, scope);
});
} else {
Object.entries(customizations).forEach(([key, value]) => {
this._setCustomization(key, value, scope);
});
}
}
/**
* @deprecated Use setCustomizations instead
*/
public setCustomization(
customizationId: string,
customization: Customization | string,
scope: CustomizationScope = CustomizationScope.Mode
): void {
console.warn(
'setCustomization is deprecated. Please use setCustomizations with an object instead.'
);
this._setCustomization(customizationId, customization, scope);
}
/**
* Internal method to set a single customization
*/
private _setCustomization(
customizationId: string,
customization: Customization,
scope: CustomizationScope = CustomizationScope.Mode
): void {
// if (typeof customization === 'string') {
// const extensionValue = this._findExtensionValue(customization);
// customization = extensionValue.value;
// }
switch (scope) {
case CustomizationScope.Global:
this.setGlobalCustomization(customizationId, customization);
break;
case CustomizationScope.Mode:
this.setModeCustomization(customizationId, customization);
break;
case CustomizationScope.Default:
this.setDefaultCustomization(customizationId, customization);
break;
default:
throw new Error(`Invalid customization scope: ${scope}`);
}
}
/**
* Gets all customizations for a given scope.
*
* @param scope - The scope to retrieve customizations from: 'global', 'mode', or 'default'
* @returns A Map containing all customizations for the specified scope
*/
public getCustomizations(scope: CustomizationScope): Map<string, Customization> {
if (scope === CustomizationScope.Global) {
return this.globalCustomizations;
}
if (scope === CustomizationScope.Mode) {
return this.modeCustomizations;
}
return this.defaultCustomizations;
}
/** /**
* Returns true if there is a mode customization. Doesn't include defaults, but * Returns true if there is a mode customization. Doesn't include defaults, but
* does return global overrides. * does return global overrides.
*/ */
public hasModeCustomization(customizationId: string) { public hasCustomization(customizationId: string) {
return ( return (
this.globalCustomizations.has(customizationId) || this.modeCustomizations.has(customizationId) this.globalCustomizations.has(customizationId) || this.modeCustomizations.has(customizationId)
); );
} }
/**
* get is an alias for getModeCustomization, as it is the generic getter
* which will return both mode and global customizations, and should be
* used generally.
* Note that the second parameter, defaultValue, will be expanded to include
* any customizationType values defined in it, so it is not the same as doing:
* `customizationService.get('key') || defaultValue`
* unless the defaultValue does not contain any customizationType definitions.
*/
public get = this.getModeCustomization;
/** /**
* Applies any inheritance due to UI Type customization. * Applies any inheritance due to UI Type customization.
* This will look for customizationType in the customization object * This will look for inheritsFrom in the customization object
* and if that is found, will assign all iterable values from that * and if that is found, will assign all iterable values from that
* type into the new type, allowing default behaviour to be configured. * type into the new type, allowing default behavior to be configured.
*/ */
public transform(customization: Customization): Customization { public transform(customization: Customization): Customization {
if (!customization) { if (!customization) {
return customization; return customization;
} }
const { customizationType } = customization; const { inheritsFrom } = customization;
if (!customizationType) { if (!inheritsFrom) {
return customization; return customization;
} }
const parent = this.getCustomization(customizationType); const parent = this.getCustomization(inheritsFrom);
const result = parent ? Object.assign({}, parent, customization) : customization; const result = parent ? Object.assign({}, parent, customization) : customization;
// Execute an nested type information // Execute an nested type information
return result.transform?.(this) || result; return result.$transform?.(this) || result;
} }
/** /**
* Helper method to easily add and retrieve customizations *
* @param id The unique identifier for the customization * Sets a mode-specific customization.
* @param defaultComponent The default component to use if no customization is set *
* @param customComponent Optional custom component to set * This method allows you to define or update a customization that applies only to the current mode.
* @returns The custom component if set, otherwise the default component * Mode customizations are temporary and isolated, reset whenever a mode changes.
*
* @param customizationId - The unique identifier for the customization.
* @param customization - The customization object containing the desired settings.
*/ */
public getCustomComponent( private setModeCustomization(customizationId: string, customization: Customization): void {
id: string, const defaultCustomization = this.defaultCustomizations.get(customizationId);
defaultComponent: React.ComponentType<any>, const modeCustomization = this.modeCustomizations.get(customizationId);
customComponent?: React.ComponentType<any> const globCustomization = this.globalCustomizations.get(customizationId);
) {
const customization = this.getCustomization(id, {
id: `default-${id}`,
content: defaultComponent,
});
if (customComponent) { const sourceCustomization =
this.setModeCustomization(id, { content: customComponent }); modeCustomization || this._cloneIfNeeded(globCustomization) || defaultCustomization;
}
return customization.content; const result = this._update(sourceCustomization, customization);
} this.modeCustomizations.set(customizationId, result);
public addModeCustomizations(modeCustomizations): void { this.transformedCustomizations.clear();
if (!modeCustomizations) { this._broadcastEvent(this.EVENTS.CUSTOMIZATION_MODIFIED, {
return; buttons: this.modeCustomizations,
} button: this.modeCustomizations.get(customizationId),
this.addReferences(modeCustomizations, CustomizationType.Mode);
this._broadcastModeCustomizationModified();
}
_broadcastModeCustomizationModified(): void {
this._broadcastEvent(EVENTS.MODE_CUSTOMIZATION_MODIFIED, {
modeCustomizations: this.modeCustomizations,
globalCustomizations: this.globalCustomizations,
}); });
} }
/** Global customizations are those that affect parts of the GUI other than private setGlobalCustomization(id: string, value: Customization): void {
* the modes. They include things like settings for the search screen. const defaultCustomization = this.defaultCustomizations.get(id);
* Reset does NOT clear global customizations. const globCustomization = this.globalCustomizations.get(id);
*/
getGlobalCustomization(id: string, defaultValue?: Customization): Customization | void { const sourceCustomization = this._cloneIfNeeded(globCustomization) || defaultCustomization;
return this.transform( this.globalCustomizations.set(id, this._update(sourceCustomization, value));
this.globalCustomizations.get(id) ?? this.defaultCustomizations.get(id) ?? defaultValue
); this.transformedCustomizations.clear();
this._broadcastEvent(this.EVENTS.DEFAULT_CUSTOMIZATION_MODIFIED, {
buttons: this.defaultCustomizations,
button: this.defaultCustomizations.get(id),
});
}
private setDefaultCustomization(id: string, value: Customization): void {
if (this.defaultCustomizations.has(id)) {
console.warn(`Trying to update existing default for customization ${id}`);
}
this.transformedCustomizations.clear();
const sourceCustomization = this.defaultCustomizations.get(id);
this.defaultCustomizations.set(id, this._update(sourceCustomization, value));
this._broadcastEvent(this.EVENTS.DEFAULT_CUSTOMIZATION_MODIFIED, {
buttons: this.defaultCustomizations,
button: this.defaultCustomizations.get(id),
});
}
private _findExtensionValue(value: string) {
const entry = this.extensionManager.getModuleEntry(value);
return entry as { value: Customization };
} }
/** /**
* Performs a merge, creating a new instance value - that is, not referencing * Registers a custom command to be used in customization updates.
* the old one. This only works if you run once for the merge, so in general, * @param commandName - The name of the command (without the $ prefix)
* the source value should be global, while the appends should be mode based. * it will be prefixed with $
* However, you can append to a global value too, as long as you ensure it * @param handler - Function that handles the command it receives the value and the original value
* only gets merged once.
*/ */
private mergeValue(oldValue, newValue, mergeType = MergeEnum.Replace) { public registerCustomUpdateCommand(
if (mergeType === MergeEnum.Replace) { commandName: string,
handler: (value: Customization, original: Customization) => Customization
): void {
if (!commandName.startsWith('$')) {
commandName = '$' + commandName;
}
extend(commandName, handler);
}
/**
* Uses immutability-helper to apply the user's commands (e.g. $set, $push, $apply, etc.)
* Takes into account the 'mergeType' if it's explicitly 'Replace'; otherwise does a normal update.
*/
private _update(oldValue: Customization | undefined, newValue: Customization): Customization {
if (!oldValue) {
oldValue = undefined;
}
// Use immutability-helper to apply the commands
// if $ is not part of the value in the json string, then we just return the newValue
if (!hasDollarKey(newValue)) {
return newValue; return newValue;
} }
const returnValue = mergeWith( const result = update(oldValue, newValue);
{}, return result;
oldValue,
newValue,
mergeType === MergeEnum.Append ? appendCustomizer : mergeCustomizer
);
return returnValue;
} }
public setGlobalCustomization(id: string, value: Customization, merge = MergeEnum.Replace): void { private _cloneIfNeeded(value: any) {
const defaultCustomization = this.defaultCustomizations.get(id); // If it's null/undefined or not an object, return as is
const globCustomization = this.globalCustomizations.get(id); if (!value || typeof value !== 'object') {
const sourceCustomization = return value;
(globCustomization && cloneDeepWith(globCustomization, cloneCustomizer)) ||
defaultCustomization ||
{};
this.globalCustomizations.set(
id,
this.mergeValue(sourceCustomization, value, value.merge ?? merge)
);
this.transformedCustomizations.clear();
this._broadcastGlobalCustomizationModified();
} }
public setDefaultCustomization( // If it's an array, create a shallow copy
id: string, if (Array.isArray(value)) {
value: Customization, return [...value];
merge = MergeEnum.Replace
): void {
if (this.defaultCustomizations.has(id)) {
throw new Error(`Trying to update existing default for customization ${id}`);
}
this.transformedCustomizations.clear();
this.defaultCustomizations.set(
id,
this.mergeValue(this.defaultCustomizations.get(id), value, merge)
);
} }
protected setConfigGlobalCustomization(configuration: AppConfigCustomization): void { // Otherwise create a shallow copy of the object
this.globalCustomizations.clear(); return { ...value };
const keys = flattenNestedStrings(configuration.globalCustomizations);
this.readCustomizationTypes(v => keys[v.name] && v.customization, this.globalCustomizations);
// TODO - iterate over customizations, loading them from the extension
// manager.
this._broadcastGlobalCustomizationModified();
} }
_broadcastGlobalCustomizationModified(): void { _addReference(value?: any, type = CustomizationScope.Global): void {
this._broadcastEvent(EVENTS.GLOBAL_CUSTOMIZATION_MODIFIED, {
modeCustomizations: this.modeCustomizations,
globalCustomizations: this.globalCustomizations,
});
}
/**
* A single reference is either an string to be loaded from a module,
* or a customization itself.
*/
addReference(value?, type = CustomizationType.Global, id?: string, merge?: MergeEnum): void {
if (!value) { if (!value) {
return; return;
} }
if (typeof value === 'string') { if (typeof value === 'string') {
const extensionValue = this.findExtensionValue(value); const extensionValue = this._findExtensionValue(value);
// The child of a reference is only a set of references when an array, value = extensionValue.value;
// so call the addReference direct. It could be a secondary reference perhaps
this.addReference(extensionValue.value, type, extensionValue.name, extensionValue.merge);
} else if (Array.isArray(value)) {
this.addReferences(value, type);
} else {
const useId = value.id || id;
const setName =
(type === CustomizationType.Global && 'setGlobalCustomization') ||
(type === CustomizationType.Default && 'setDefaultCustomization') ||
'setModeCustomization';
this[setName](useId as string, value, merge);
} }
Object.entries(value).forEach(([id, customization]) => {
const setName =
(type === CustomizationScope.Global && 'setGlobalCustomization') ||
(type === CustomizationScope.Default && 'setDefaultCustomization') ||
'setModeCustomization';
this[setName](id as string, customization as Customization);
});
} }
/** /**
@ -433,94 +434,106 @@ export default class CustomizationService extends PubSubService {
* or as an object whose key is the reference id, and the value is the string * or as an object whose key is the reference id, and the value is the string
* or customization. * or customization.
*/ */
addReferences(references?, type = CustomizationType.Global): void { addReferences(references?: any, type = CustomizationScope.Global): void {
if (!references) { if (!references) {
return; return;
} }
if (Array.isArray(references)) { if (Array.isArray(references)) {
references.forEach(item => { references.forEach(item => {
this.addReference(item, type); this._addReference(item, type);
}); });
} else { } else {
for (const key of Object.keys(references)) { this._addReference(references, type);
const value = references[key];
this.addReference(value, type, key);
}
} }
} }
} }
/** /** Add custom $filter command */
* Custom merging function, to handle merging arrays and copying functions extend('$filter', (query, original) => {
*/ // This helper checks if an object matches all key/value pairs in `match`
function appendCustomizer(obj, src) { function objectMatches(item, matchObj) {
if (Array.isArray(obj)) { return (
const srcArray = Array.isArray(src); item && typeof item === 'object' && Object.entries(matchObj).every(([k, v]) => item[k] === v)
if (srcArray) {
return obj.concat(...src);
}
if (typeof src === 'object') {
const newList = obj.map(value => cloneDeepWith(value, cloneCustomizer));
for (const [key, value] of Object.entries(src)) {
const { position, isMerge } = findPosition(key, value, newList);
if (isMerge) {
if (typeof obj[position] === 'object') {
newList[position] = mergeWith(
Array.isArray(newList[position]) ? [] : {},
newList[position],
value,
appendCustomizer
); );
} else {
newList[position] = value;
}
} else {
newList.splice(position, 0, value);
}
}
return newList;
}
return obj.concat(src);
}
return cloneCustomizer(src);
} }
function mergeCustomizer(obj, src) { // Recursively walk objects/arrays. Whenever we hit an array, we either filter
return cloneCustomizer(src); // or update items that match, depending on what was passed in via `query`.
function deepFilter(value, filterQuery) {
// If it's an array, apply the filtering/updating logic to each item
if (Array.isArray(value)) {
let result = value;
// 1) If it's a function, filter array items
if (typeof filterQuery === 'function') {
return value.filter(filterQuery);
} }
function findPosition(key, value, newList) { // 2) If it's a string, remove items whose .id matches that string
const numVal = Number(key); if (typeof filterQuery === 'string') {
const isNumeric = !isNaN(numVal); return value.filter(item => item.id !== filterQuery);
const { length: len } = newList;
if (isNumeric) {
if (newList[numVal < 0 ? numVal + len : numVal]) {
return { isMerge: true, position: (numVal + len) % len };
}
const absPosition = Math.ceil(numVal < 0 ? len + numVal : numVal);
return { isMerge: false, position: Math.min(len, Math.max(absPosition, 0)) };
}
const findIndex = newList.findIndex(it => it.id === key);
if (findIndex !== -1) {
return { isMerge: true, position: findIndex };
}
const { _priority: priority } = value;
if (priority !== undefined) {
if (newList[(priority + len) % len]) {
return { isMerge: true, position: (priority + len) % len };
}
const absPosition = Math.ceil(priority < 0 ? len + priority : priority);
return { isMerge: false, position: Math.min(len, Math.max(absPosition, 0)) };
}
return { isMerge: false, position: len };
} }
/** // 3) If it's an object with .match and .merge, apply the merge to matched items
* Custom cloning function to just copy function reference if (typeof filterQuery === 'object' && filterQuery.match && filterQuery.$merge) {
*/ // First recurse into sub-objects/arrays so we handle deeply nested arrays
function cloneCustomizer(value) { result = value.map(item => deepFilter(item, filterQuery));
if (typeof value === 'function') { // Then update items that match
return result.map(item => {
if (objectMatches(item, filterQuery.match)) {
return { ...item, ...filterQuery.$merge };
}
return item;
});
}
// 4) If it's an object with .id and .$merge, for backwards-compat
if (typeof filterQuery === 'object' && filterQuery.id && filterQuery.$merge) {
result = value.map(item => deepFilter(item, filterQuery));
return result.map(item => {
if (item.id === filterQuery.id) {
return { ...item, ...filterQuery.$merge };
}
return item;
});
}
// Otherwise, just recurse into sub-objects without filtering
return value.map(item => deepFilter(item, filterQuery));
}
// If it's a plain object, recurse into its properties
if (value && typeof value === 'object') {
const newObj = { ...value };
for (const [key, val] of Object.entries(newObj)) {
newObj[key] = deepFilter(val, filterQuery);
}
return newObj;
}
// If it's neither array nor object, just return it
return value; return value;
} }
return deepFilter(original, query);
});
function hasDollarKey(value) {
if (Array.isArray(value)) {
for (const item of value) {
if (hasDollarKey(item)) {
return true;
}
}
} else if (value && typeof value === 'object') {
for (const key of Object.keys(value)) {
if (key.startsWith('$') && key !== '$transform') {
return true;
}
if (hasDollarKey(value[key])) {
return true;
}
}
}
return false;
} }

View File

@ -4,8 +4,8 @@ import { ComponentType } from 'react';
export type Obj = Record<string, unknown>; export type Obj = Record<string, unknown>;
export interface BaseCustomization extends Obj { export interface BaseCustomization extends Obj {
id: string; id?: string;
customizationType?: string; inheritsFrom?: string;
description?: string; description?: string;
label?: string; label?: string;
commands?: Command[]; commands?: Command[];

View File

@ -434,8 +434,10 @@ export default class HangingProtocolService extends PubSubService {
this._setProtocol(matchedProtocol); this._setProtocol(matchedProtocol);
} }
if (this.protocol?.callbacks?.onProtocolEnter) {
this._commandsManager.run(this.protocol?.callbacks?.onProtocolEnter); this._commandsManager.run(this.protocol?.callbacks?.onProtocolEnter);
} }
}
/** /**
* Returns true, if the hangingProtocol has a custom loading strategy for the images * Returns true, if the hangingProtocol has a custom loading strategy for the images
@ -1028,7 +1030,9 @@ export default class HangingProtocolService extends PubSubService {
// before reassigning the protocol, we need to check if there is a callback // before reassigning the protocol, we need to check if there is a callback
// on the old protocol that needs to be called // on the old protocol that needs to be called
// Send the notification about updating the state // Send the notification about updating the state
this._commandsManager.run(this.protocol?.callbacks?.onProtocolExit); if (this.protocol?.callbacks?.onProtocolExit) {
this._commandsManager.run(this.protocol.callbacks.onProtocolExit);
}
this.protocol = protocol; this.protocol = protocol;

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 277 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

View File

@ -67,12 +67,12 @@ customizationService.addModeCustomizations([
customizationService.addModeCustomizations([ customizationService.addModeCustomizations([
// To disable editing in the SegmentationTable // To disable editing in the SegmentationTable
{ {
id: 'PanelSegmentation.disableEditing', id: 'panelSegmentation.disableEditing',
disableEditing: true, disableEditing: true,
}, },
// To disable editing in the MeasurementTable // To disable editing in the MeasurementTable
{ {
id: 'PanelMeasurement.disableEditing', id: 'panelMeasurement.disableEditing',
disableEditing: true, disableEditing: true,
}, },
]) ])
@ -106,11 +106,11 @@ customizationService.addModeCustomizations([
```js ```js
customizationService.addModeCustomizations([ customizationService.addModeCustomizations([
{ {
id: 'PanelSegmentation.tableMode', id: 'panelSegmentation.tableMode',
mode: 'expanded', mode: 'expanded',
}, },
{ {
id: 'PanelSegmentation.onSegmentationAdd', id: 'panelSegmentation.onSegmentationAdd',
onSegmentationAdd: () => { onSegmentationAdd: () => {
commandsManager.run('createNewLabelmapFromPT'); commandsManager.run('createNewLabelmapFromPT');
}, },

Some files were not shown because too many files have changed in this diff Show More