diff --git a/extensions/tmtv/src/commandsModule.ts b/extensions/tmtv/src/commandsModule.ts
index ce9bd7ff9..75de0c5ec 100644
--- a/extensions/tmtv/src/commandsModule.ts
+++ b/extensions/tmtv/src/commandsModule.ts
@@ -420,7 +420,13 @@ const commandsModule = ({ servicesManager, commandsManager, extensionManager }:
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 }) => {
const segReport = commandsManager.runCommand('getSegmentationCSVReport', {
diff --git a/modes/basic-test-mode/src/index.ts b/modes/basic-test-mode/src/index.ts
index b37a0bd3b..13663925e 100644
--- a/modes/basic-test-mode/src/index.ts
+++ b/modes/basic-test-mode/src/index.ts
@@ -86,7 +86,7 @@ function modeFactory() {
initToolGroups(extensionManager, toolGroupService, commandsManager);
// init customizations
- customizationService.addModeCustomizations([
+ customizationService.setCustomizations([
'@ohif/extension-test.customizationModule.custom-context-menu',
]);
diff --git a/modes/longitudinal/src/customizations.tsx b/modes/longitudinal/src/customizations.tsx
deleted file mode 100644
index 279b611ff..000000000
--- a/modes/longitudinal/src/customizations.tsx
+++ /dev/null
@@ -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,
- // }) => (
- //
- // onSegmentationDelete(activeSegmentation.id)}>
- //
- // {t('My Custom Dropdown Menu Item')}
- //
- //
- // ),
- // },
- ]);
-};
diff --git a/modes/longitudinal/src/index.ts b/modes/longitudinal/src/index.ts
index 6afe135fa..784fa87ef 100644
--- a/modes/longitudinal/src/index.ts
+++ b/modes/longitudinal/src/index.ts
@@ -4,7 +4,6 @@ import { id } from './id';
import initToolGroups from './initToolGroups';
import toolbarButtons from './toolbarButtons';
import moreTools from './moreTools';
-import { performCustomizations } from './customizations';
// Allow this mode by excluding non-imaging modalities such as SR, SEG
// Also, SM is not a simple imaging modalities, so exclude it.
@@ -89,15 +88,12 @@ function modeFactory({ modeConfiguration }) {
measurementService,
toolbarService,
toolGroupService,
- customizationService,
panelService,
segmentationService,
} = servicesManager.services;
measurementService.clearMeasurements();
- performCustomizations(customizationService);
-
// Init Default and SR ToolGroups
initToolGroups(extensionManager, toolGroupService, commandsManager, this.labelConfig);
diff --git a/modes/preclinical-4d/src/index.tsx b/modes/preclinical-4d/src/index.tsx
index a7ad7a7ae..6d323115b 100644
--- a/modes/preclinical-4d/src/index.tsx
+++ b/modes/preclinical-4d/src/index.tsx
@@ -62,36 +62,19 @@ function modeFactory({ modeConfiguration }) {
// the primary button section is created in the workflow steps
// specific to the step
- customizationService.addModeCustomizations([
- {
- id: 'PanelSegmentation.tableMode',
- mode: 'expanded',
+ customizationService.setCustomizations({
+ 'panelSegmentation.tableMode': {
+ $set: 'expanded',
},
- {
- id: 'PanelSegmentation.onSegmentationAdd',
- onSegmentationAdd: () => {
+ 'panelSegmentation.onSegmentationAdd': {
+ $set: () => {
commandsManager.run('createNewLabelMapForDynamicVolume');
},
},
- {
- id: 'PanelSegmentation.showAddSegment',
- showAddSegment: false,
+ 'panelSegmentation.showAddSegment': {
+ $set: 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
const { unsubscribe } = cornerstoneViewportService.subscribe(
diff --git a/modes/tmtv/src/index.ts b/modes/tmtv/src/index.ts
index e1dbfffcb..aa59c00a3 100644
--- a/modes/tmtv/src/index.ts
+++ b/modes/tmtv/src/index.ts
@@ -101,33 +101,16 @@ function modeFactory({ modeConfiguration }) {
'BrushTools',
]);
- customizationService.addModeCustomizations([
- {
- id: 'PanelSegmentation.tableMode',
- mode: 'expanded',
+ customizationService.setCustomizations({
+ 'panelSegmentation.tableMode': {
+ $set: 'expanded',
},
- {
- id: 'PanelSegmentation.onSegmentationAdd',
- onSegmentationAdd: () => {
+ 'panelSegmentation.onSegmentationAdd': {
+ $set: () => {
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
// based on whether the SUV is corrected or not, hence we can't hard
diff --git a/platform/app/public/config/customization.js b/platform/app/public/config/customization.js
index c61eb3970..aee6a23ee 100644
--- a/platform/app/public/config/customization.js
+++ b/platform/app/public/config/customization.js
@@ -15,52 +15,6 @@ window.config = {
customizationService: [
'@ohif/extension-default.customizationModule.datasources',
'@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',
diff --git a/platform/app/src/routes/Mode/defaultRouteInit.ts b/platform/app/src/routes/Mode/defaultRouteInit.ts
index e425e9484..fceb7573c 100644
--- a/platform/app/src/routes/Mode/defaultRouteInit.ts
+++ b/platform/app/src/routes/Mode/defaultRouteInit.ts
@@ -1,10 +1,8 @@
import getStudies from './studiesList';
-import { DicomMetadataStore, log } from '@ohif/core';
+import { DicomMetadataStore, log, utils, Enums } from '@ohif/core';
import isSeriesFilterUsed from '../../utils/isSeriesFilterUsed';
-import { utils, Enums } from '@ohif/core';
-
-const { sortingCriteria, getSplitParam } = utils;
+const { getSplitParam } = utils;
/**
* Initialize the route.
@@ -85,9 +83,7 @@ export async function defaultRouteInit(
StudyInstanceUID,
filters,
returnPromises: true,
- sortCriteria:
- customizationService.get('sortingCriteria') ||
- sortingCriteria.seriesSortCriteria.seriesInfoSortingCriteria,
+ sortCriteria: customizationService.getCustomization('sortingCriteria'),
})
);
diff --git a/platform/app/src/routes/WorkList/WorkList.tsx b/platform/app/src/routes/WorkList/WorkList.tsx
index 0475a5746..96f067631 100644
--- a/platform/app/src/routes/WorkList/WorkList.tsx
+++ b/platform/app/src/routes/WorkList/WorkList.tsx
@@ -525,8 +525,7 @@ function WorkList({
}
const { customizationService } = servicesManager.services;
- const { component: DicomUploadComponent } =
- customizationService.getCustomization('dicomUploadComponent') || {};
+ const DicomUploadComponent = customizationService.getCustomization('dicomUploadComponent');
const uploadProps =
DicomUploadComponent && dataSource.getConfig()?.dicomUploadEnabled
@@ -554,8 +553,9 @@ function WorkList({
}
: undefined;
- const { component: dataSourceConfigurationComponent } =
- customizationService.get('ohif.dataSourceConfigurationComponent') ?? {};
+ const dataSourceConfigurationComponent = customizationService.getCustomization(
+ 'ohif.dataSourceConfigurationComponent'
+ );
return (
diff --git a/platform/app/src/routes/index.tsx b/platform/app/src/routes/index.tsx
index 5a4e5c510..1dd0c634f 100644
--- a/platform/app/src/routes/index.tsx
+++ b/platform/app/src/routes/index.tsx
@@ -108,7 +108,8 @@ const createRoutes = ({
props: { children: WorkList, servicesManager, extensionManager },
};
- const customRoutes = customizationService.getGlobalCustomization('customRoutes');
+ const customRoutes = customizationService.getCustomization('routes.customRoutes');
+
const allRoutes = [
...routes,
...(showStudyList ? [WorkListRoute] : []),
diff --git a/platform/core/package.json b/platform/core/package.json
index 17f77a918..6a37120ed 100644
--- a/platform/core/package.json
+++ b/platform/core/package.json
@@ -48,6 +48,7 @@
"dicomweb-client": "^0.10.4",
"gl-matrix": "^3.4.3",
"isomorphic-base64": "^1.0.2",
+ "immutability-helper": "^3.1.1",
"lodash.clonedeep": "^4.5.0",
"lodash.merge": "^4.6.2",
"lodash.mergewith": "^4.6.2",
diff --git a/platform/core/src/classes/CommandsManager.ts b/platform/core/src/classes/CommandsManager.ts
index 701621eb6..ef58869c1 100644
--- a/platform/core/src/classes/CommandsManager.ts
+++ b/platform/core/src/classes/CommandsManager.ts
@@ -94,16 +94,29 @@ export class CommandsManager {
* @param {CommandDefinition} definition - {@link CommandDefinition}
*/
registerCommand(contextName, commandName, definition) {
- if (typeof definition !== 'object') {
+ if (typeof definition !== 'object' && typeof definition !== 'function') {
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);
if (!context) {
return;
}
- context[commandName] = definition;
+ if (typeof definition === 'function') {
+ context[commandName] = { commandFn: definition, options: {} };
+ } else {
+ context[commandName] = definition;
+ }
}
/**
@@ -138,6 +151,11 @@ export class CommandsManager {
* @param {String} [contextName]
*/
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);
if (!definition) {
log.warn(`Command "${commandName}" not found in current context`);
@@ -147,7 +165,7 @@ export class CommandsManager {
const { commandFn } = definition;
const commandParams = Object.assign(
{},
- definition.options, // "Command configuration"
+ definition.options || {}, // "Command configuration"
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') {
return [{ commandName: toRun }];
}
if ('commandName' in toRun) {
return [toRun as ComplexCommand];
}
+ if (typeof toRun === 'function') {
+ return [{ commandName: toRun }];
+ }
if ('commands' in toRun) {
const commandsInput = (toRun as Commands).commands;
return this.convertCommands(commandsInput);
@@ -203,7 +224,7 @@ export class CommandsManager {
*
* Example commands to run are:
* * 'updateMeasurement'
- * * `{commandName: 'displayWhatever'}`
+ * * `{ commandName: 'displayWhatever'}`
* * `['updateMeasurement', {commandName: 'displayWhatever'}]`
* * `{ commands: 'updateMeasurement' }`
* * `{ commands: ['updateMeasurement', {commandName: 'displayWhatever'}]}`
diff --git a/platform/core/src/services/CustomizationService/CustomizationService.test.js b/platform/core/src/services/CustomizationService/CustomizationService.test.js
index 98c407fe9..84e049e07 100644
--- a/platform/core/src/services/CustomizationService/CustomizationService.test.js
+++ b/platform/core/src/services/CustomizationService/CustomizationService.test.js
@@ -1,313 +1,497 @@
-import CustomizationService, { CustomizationType, MergeEnum } from './CustomizationService';
-import log from '../../log';
-
-jest.mock('../../log.js', () => ({
- info: jest.fn(),
- warn: jest.fn(),
- error: jest.fn(),
-}));
+// File: CustomizationService.registrationAndOperations.test.js
+import CustomizationService, { CustomizationScope } from './CustomizationService';
+const commandsManager = {};
const extensionManager = {
registeredExtensionIds: [],
moduleEntries: {},
-
getRegisteredExtensionIds: () => extensionManager.registeredExtensionIds,
-
getModuleEntry: function (id) {
return this.moduleEntries[id];
},
};
-const commandsManager = {};
+const noop = () => {};
-const ohifOverlayItem = {
- id: 'ohif.overlayItem',
- content: function (props) {
- return {
- label: this.label,
- value: props[this.attribute],
- ver: 'default',
- };
- },
-};
+// A helper default customization module that mimics the structure returned by the module.
+function getDefaultCustomizationModule() {
+ return {
+ // Simple types
+ showAddSegment: true,
+ 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 = {
- id: 'testItem',
- customizationType: 'ohif.overlayItem',
- attribute: 'testAttribute',
- label: 'testItemLabel',
-};
-
-describe('CustomizationService.ts', () => {
+describe('CustomizationService - Registration + API Operations', () => {
let customizationService;
- let configuration;
-
beforeEach(() => {
- log.warn.mockClear();
- jest.clearAllMocks();
- configuration = {};
- customizationService = new CustomizationService({
- configuration,
- commandsManager,
- });
- extensionManager.registeredExtensionIds = [];
- extensionManager.moduleEntries = {};
+ customizationService = new CustomizationService({ commandsManager, configuration: {} });
+
+ // Simulate default registrations.
+ customizationService.addReferences(getDefaultCustomizationModule(), CustomizationScope.Default);
});
- describe('init', () => {
- it('init succeeds', () => {
- customizationService.init(extensionManager);
+ afterEach(() => {
+ customizationService.onModeExit();
+ });
+
+ // Check that defaults are registered
+ it('has registered default customizations', () => {
+ const defaultShowAddSegment = customizationService.getCustomization('showAddSegment');
+ 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');
+ });
+
+ // 1. Simple Data Types
+ describe('Simple Data Types', () => {
+ it('replaces boolean value using $set over the default', () => {
+ // Update the default value with a new one using $set.
+ customizationService.setCustomizations({
+ showAddSegment: { $set: false },
+ });
+ const result = customizationService.getCustomization('showAddSegment');
+
+ // Mode/global should override the default.
+ expect(result).toBe(false);
});
- it('configurationRegistered', () => {
- configuration.testItem = testItem;
- customizationService.init(extensionManager);
- expect(customizationService.getGlobalCustomization('testItem')).toBe(testItem);
+ 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('defaultRegistered', () => {
- extensionManager.registeredExtensionIds.push('@testExtension');
- extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
- name: 'default',
- value: [testItem],
- };
- customizationService.init(extensionManager);
- expect(customizationService.getGlobalCustomization('testItem')).toBe(testItem);
+ 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', () => {
- it('inherits type', () => {
- extensionManager.registeredExtensionIds.push('@testExtension');
- extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
- name: 'default',
- value: [ohifOverlayItem],
- };
- configuration.testItem = testItem;
- 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');
+ // 2. Arrays of Primitives
+ describe('Arrays of Primitives', () => {
+ it('replaces entire array with $set over default', () => {
+ customizationService.setCustomizations({
+ NumbersList: { $set: [5, 6, 7, 8, 9] },
+ });
+ const result = customizationService.getCustomization('NumbersList');
+ expect(result).toEqual([5, 6, 7, 8, 9]);
});
- it('inline default inherits type', () => {
- extensionManager.registeredExtensionIds.push('@testExtension');
- extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
- name: 'default',
- value: [ohifOverlayItem],
- };
- configuration.testItem = testItem;
- customizationService.init(extensionManager);
-
- const item = customizationService.getCustomization('testItem2', {
- id: 'testItem2',
- customizationType: 'ohif.overlayItem',
- label: 'otherLabel',
- attribute: 'otherAttr',
+ it('applies $push, $unshift, and $splice to default array', () => {
+ // Update array using merge commands
+ customizationService.setCustomizations({
+ NumbersList: {
+ $push: [5, 6],
+ },
});
+ const result = customizationService.getCustomization('NumbersList');
+ expect(result).toEqual([1, 2, 3, 4, 5, 6]);
+ });
- // 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');
+ it('applies $push, $unshift, and $splice to default array', () => {
+ // Update array using merge commands
+ customizationService.setCustomizations({
+ NumbersList: {
+ $unshift: [0],
+ },
+ });
+ const result = customizationService.getCustomization('NumbersList');
+ expect(result).toEqual([0, 1, 2, 3, 4]);
+ });
+
+ it('applies $push, $unshift, and $splice to default array', () => {
+ // Update array using merge commands
+ customizationService.setCustomizations({
+ NumbersList: {
+ $splice: [
+ [2, 1, 99], // At index 2, remove
+ ],
+ },
+ });
+ const result = customizationService.getCustomization('NumbersList');
+ expect(result).toEqual([1, 2, 99, 4]);
+ });
+
+ it('applies $push, $unshift, and $splice to default array', () => {
+ // Update array using merge commands
+ customizationService.setCustomizations({
+ NumbersList: {
+ $push: [5, 6],
+ $unshift: [0],
+ },
+ });
+ const result = customizationService.getCustomization('NumbersList');
+
+ expect(result).toEqual([0, 1, 2, 3, 4, 5, 6]);
});
});
- describe('mode customization', () => {
- it('onModeEnter can add extensions', () => {
- extensionManager.registeredExtensionIds.push('@testExtension');
- extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
- name: 'default',
- value: [ohifOverlayItem],
- };
- customizationService.init(extensionManager);
+ // 3. Objects
+ 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');
- 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');
+ expect(result.label).toBe('Series Number');
+ expect(result.sortFunction).not.toEqual(noop);
+ expect(result.views).toEqual(['3D']);
});
- it('global customizations override modes', () => {
- extensionManager.registeredExtensionIds.push('@testExtension');
- extensionManager.moduleEntries['@testExtension.customizationModule.global'] = {
- name: 'default',
- value: [ohifOverlayItem],
- };
- configuration.testItem = testItem;
- customizationService.init(extensionManager);
+ 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');
- // Add a mode customization that would otherwise fail below
- customizationService.addModeCustomizations([{ ...testItem, label: 'other' }]);
+ expect(result.label).toBe('New Label');
+ expect(result.extraField).toBe(true);
- 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);
+ // 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('mode customizations override default', () => {
- extensionManager.registeredExtensionIds.push('@testExtension');
- extensionManager.moduleEntries['@testExtension.customizationModule.default'] = {
- name: 'default',
- value: [ohifOverlayItem, testItem],
- };
- customizationService.init(extensionManager);
+ 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');
- // 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);
+ expect(result.label).toBe('Series Number (via $apply)');
});
});
- 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' }],
+ // 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),
+ },
+ ],
},
- MergeEnum.Append
- );
- const appendSet2 = customizationService.getCustomization('appendSet');
- expect(appendSet2.values.length).toBe(3);
+ });
+ 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('appends mode to default without touching default', () => {
- customizationService.init(extensionManager);
-
- customizationService.setDefaultCustomization('appendSet', {
- values: [{ id: 'one' }, { id: 'two' }],
- });
- const appendSet = customizationService.get('appendSet');
- expect(appendSet.values.length).toBe(2);
-
- customizationService.setModeCustomization(
- 'appendSet',
- {
- values: [{ id: 'three' }],
+ 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,
+ },
+ ],
},
- MergeEnum.Append
- );
+ });
- expect(appendSet.values.length).toBe(2);
- const appendSet2 = customizationService.getModeCustomization('appendSet');
- expect(appendSet2.values.length).toBe(3);
+ 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,
+ 0,
+ {
+ id: 'anotherItem',
+ label: 'Another Item',
+ sortFunction: noop,
+ },
+ ],
+ ],
+ },
+ });
+ 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('merges values by name/position', () => {
- customizationService.init(extensionManager);
-
- customizationService.setDefaultCustomization('appendSet', {
- values: [{ id: 'one', obj: { v: '5' }, list: [1, 2, 3] }, { id: 'two' }],
- });
- 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] }],
+ 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)',
+ },
+ },
},
- MergeEnum.Merge,
- );
+ });
- const appendSet2 = customizationService.get('appendSet');
- const [value0] = appendSet2.values;
- expect(value0.id).toBe('three');
- expect(value0.list).toEqual([3, 2, 1, 4]);
+ const result = customizationService.getCustomization('advanced');
+
+ expect(result.functions.length).toBe(1);
+ expect(result.functions[0].label).toBe('Series Data (via $filter)');
});
- it('merges functions', () => {
- customizationService.init(extensionManager);
-
- customizationService.setDefaultCustomization('appendSet', {
- values: [{ f: () => 0, id: '0' }, { f: () => 5, id: '5' }],
+ it('updates deeply nested view functions using $filter', () => {
+ customizationService.setCustomizations({
+ advanced: {
+ $filter: {
+ match: { id: 'axial' },
+ $merge: {
+ label: 'Axial (via $filter)',
+ },
+ },
+ },
});
- 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('advanced');
- const appendSet2 = customizationService.get('appendSet');
- const [value0, value1] = appendSet2.values;
- expect(value0.f()).toBe(2);
- expect(value1.f()).toBe(5);
+ expect(result.functions.length).toBe(1);
+ expect(result.functions[0].viewFunctions[2].label).toBe('Axial (via $filter)');
});
+ });
- it('merges list with object', () => {
- customizationService.init(extensionManager);
+ // 6. Multiple Default Registrations
+ describe('Multiple Default Registrations', () => {
+ it('allows subsequent default registrations to enhance previous ones', () => {
+ customizationService = new CustomizationService({ commandsManager, configuration: {} });
- const destination = [
- 1,
- { id: 'two', value: 2, list: [5, 6], },
- { id: 'three', value: 3 }
- ];
-
- const source = {
- two: { value: 'updated2', list: { 0: 8 } },
- 1: { extraValue: 2, list: [7], },
- 1.0001: { id: 'inserted', value: 1.0001 },
- '-1': {
- value: -3
- },
+ // First extension registers its defaults
+ const firstExtensionDefaults = {
+ simpleList: [1, 2, 3],
};
+ customizationService.addReferences(firstExtensionDefaults, CustomizationScope.Default);
- customizationService.setDefaultCustomization('appendSet', {
- values: destination,
+ // Second extension enhances the first one's defaults
+ const secondExtensionDefaults = {
+ simpleList: { $push: [4, 5] },
+ };
+ customizationService.addReferences(secondExtensionDefaults, CustomizationScope.Default);
+
+ // Verify the final state combines both extensions' contributions
+ const result = customizationService.getCustomization('simpleList');
+ expect(result).toEqual([1, 2, 3, 4, 5]);
+ });
+ });
+
+ describe('CustomizationService - Inheritance (`inheritsFrom`)', () => {
+ 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',
+ },
+ },
});
- customizationService.setModeCustomization('appendSet', {
- values: source,
- }, MergeEnum.Append);
- const { values } = customizationService.getCustomization('appendSet');
- const [zero, one, two, three] = values;
- expect(zero).toBe(1);
- expect(one.value).toBe('updated2');
- expect(one.extraValue).toBe(2);
- expect(one.list).toEqual([8, 6, 7]);
- expect(two.id).toBe('inserted');
- expect(three.value).toBe(-3);
+ 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);
});
});
});
diff --git a/platform/core/src/services/CustomizationService/CustomizationService.ts b/platform/core/src/services/CustomizationService/CustomizationService.ts
index 4830dfe59..e8ffa6001 100644
--- a/platform/core/src/services/CustomizationService/CustomizationService.ts
+++ b/platform/core/src/services/CustomizationService/CustomizationService.ts
@@ -1,54 +1,36 @@
-import { mergeWith, cloneDeepWith } from 'lodash';
-
+import update, { extend } from 'immutability-helper';
import { PubSubService } from '../_shared/pubSubServiceInterface';
-import type { Customization, NestedStrings } from './types';
+import type { Customization } from './types';
import type { CommandsManager } from '../../classes';
import type { ExtensionManager } from '../../extensions';
const EVENTS = {
MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified',
GLOBAL_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:globalModified',
+ DEFAULT_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:defaultModified',
};
-const flattenNestedStrings = (
- strs: NestedStrings | string,
- ret?: Record
-): Record => {
- if (!ret) {
- ret = {};
- }
- if (!strs) {
- return ret;
- }
- if (Array.isArray(strs)) {
- for (const val of strs) {
- flattenNestedStrings(val, ret);
- }
- } else {
- ret[strs] = strs;
- }
- return ret;
-};
+/**
+ * Enum representing the different scopes of customizations available in the system.
+ */
+export enum CustomizationScope {
+ /**
+ * Global customizations that override both mode and default customizations.
+ * These are applied universally across the application.
+ */
+ Global = 'global',
-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',
- /**
- * Merge values, replacing arrays
- */
- Merge = 'Merge',
- /**
- * Replace the given value - this is the default
- */
- Replace = 'Replace',
-}
+ Mode = 'mode',
-export enum CustomizationType {
- Global = 'Global',
- Mode = 'Mode',
- Default = 'Default',
+ /**
+ * Default customizations that serve as fallbacks when no global or mode-specific
+ * customizations are defined. These can only be defined once.
+ */
+ Default = 'default',
}
/**
@@ -78,9 +60,12 @@ export enum CustomizationType {
* every module for the given id and to load it/add it to the extensions.
*/
export default class CustomizationService extends PubSubService {
+ public static EVENTS = EVENTS;
+ public Scope = CustomizationScope;
+
public static REGISTRATION = {
name: 'customizationService',
- create: ({ configuration = {}, commandsManager }) => {
+ create: ({ configuration, commandsManager }) => {
return new CustomizationService({ configuration, commandsManager });
},
};
@@ -89,22 +74,25 @@ export default class CustomizationService extends PubSubService {
extensionManager: ExtensionManager;
/**
- * mode customizations are changes to the default behaviour which are reset
- * every time a new mode is entered. This allows the mode to define custom
- * behaviour, and not interfere with other modes.
- */
- private modeCustomizations = new Map();
- /**
- * 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.
+ * A collection of global customizations that act as a priority layer.
+ * These customizations are applied universally, overriding both mode-specific
+ * and default customizations. Ideal for system-wide changes.
*/
private globalCustomizations = new Map();
/**
- * Default customizations allow applying default values. The intent is that
- * there is only one customization of that type, and it is registered at setup
- * time.
+ * A collection of mode-specific customizations. These allow modes to define
+ * their own behavior without impacting other modes. These customizations
+ * are cleared and redefined whenever a mode changes, ensuring isolation
+ * between modes. Read more about modes in the modes documentation.
+ */
+ private modeCustomizations = new Map();
+
+ /**
+ * 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();
@@ -113,13 +101,12 @@ export default class CustomizationService extends PubSubService {
* transform every time a customization is requested.
*/
private transformedCustomizations = new Map();
-
- configuration: any;
+ private configuration: AppTypes.Config;
constructor({ configuration, commandsManager }) {
super(EVENTS);
+ this.configuration = configuration;
this.commandsManager = commandsManager;
- this.configuration = configuration || {};
}
public init(extensionManager: ExtensionManager): void {
@@ -128,33 +115,36 @@ export default class CustomizationService extends PubSubService {
this.defaultCustomizations.clear();
// Clear modes because those are defined in onModeEnter functions.
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);
}
- 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 {
+ this.clearTransformedCustomizations();
+
+ this.init(this.extensionManager);
+ }
+
+ public onModeExit(): void {
+ this.clearTransformedCustomizations();
+ }
+
+ private clearTransformedCustomizations(): void {
super.reset();
const modeCustomizationKeys = Array.from(this.modeCustomizations.keys());
@@ -165,65 +155,19 @@ export default class CustomizationService extends PubSubService {
this.modeCustomizations.clear();
}
- public onModeExit(): void {
- this.onModeEnter();
- }
-
- public getModeCustomizations(): Map {
- 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,
- * getting global (priority) customizations first,
- * then mode customizations, and finally the default customization.
+ * Unified getter for customizations.
*
- * @param customizationId - the customization id to look for
- * @param defaultValue - is the default value to return.
- * This value will be assigned as the default customization if there isn't
- * currently a default customization, and thus, the first default provided
- * 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)
+ * @param customizationId - The ID of the customization to retrieve.
+ * @param scope - (Optional) The scope to retrieve from: 'global', 'mode', or 'default'.
+ * If not specified, it retrieves based on priority: global > mode > default.
+ * @returns The requested customization.
*/
- public getCustomization(customizationId: string, defaultValue?: Customization): Customization {
+ public getCustomization(customizationId: string): Customization {
const transformed = this.transformedCustomizations.get(customizationId);
if (transformed) {
return transformed;
}
- if (defaultValue && !this.defaultCustomizations.has(customizationId)) {
- this.setDefaultCustomization(customizationId, defaultValue);
- }
const customization =
this.globalCustomizations.get(customizationId) ??
this.modeCustomizations.get(customizationId) ??
@@ -235,197 +179,254 @@ export default class CustomizationService extends PubSubService {
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,
+ 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 {
+ 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
* does return global overrides.
*/
- public hasModeCustomization(customizationId: string) {
+ public hasCustomization(customizationId: string) {
return (
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.
- * 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
- * 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 {
if (!customization) {
return customization;
}
- const { customizationType } = customization;
- if (!customizationType) {
+ const { inheritsFrom } = customization;
+ if (!inheritsFrom) {
return customization;
}
- const parent = this.getCustomization(customizationType);
+ const parent = this.getCustomization(inheritsFrom);
const result = parent ? Object.assign({}, parent, customization) : customization;
// 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
- * @param defaultComponent The default component to use if no customization is set
- * @param customComponent Optional custom component to set
- * @returns The custom component if set, otherwise the default component
+ *
+ * Sets a mode-specific customization.
+ *
+ * This method allows you to define or update a customization that applies only to the current mode.
+ * 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(
- id: string,
- defaultComponent: React.ComponentType,
- customComponent?: React.ComponentType
- ) {
- const customization = this.getCustomization(id, {
- id: `default-${id}`,
- content: defaultComponent,
- });
+ private setModeCustomization(customizationId: string, customization: Customization): void {
+ const defaultCustomization = this.defaultCustomizations.get(customizationId);
+ const modeCustomization = this.modeCustomizations.get(customizationId);
+ const globCustomization = this.globalCustomizations.get(customizationId);
- if (customComponent) {
- this.setModeCustomization(id, { content: customComponent });
- }
+ const sourceCustomization =
+ modeCustomization || this._cloneIfNeeded(globCustomization) || defaultCustomization;
- return customization.content;
- }
+ const result = this._update(sourceCustomization, customization);
+ this.modeCustomizations.set(customizationId, result);
- public addModeCustomizations(modeCustomizations): void {
- if (!modeCustomizations) {
- return;
- }
- this.addReferences(modeCustomizations, CustomizationType.Mode);
-
- this._broadcastModeCustomizationModified();
- }
-
- _broadcastModeCustomizationModified(): void {
- this._broadcastEvent(EVENTS.MODE_CUSTOMIZATION_MODIFIED, {
- modeCustomizations: this.modeCustomizations,
- globalCustomizations: this.globalCustomizations,
+ this.transformedCustomizations.clear();
+ this._broadcastEvent(this.EVENTS.CUSTOMIZATION_MODIFIED, {
+ buttons: this.modeCustomizations,
+ button: this.modeCustomizations.get(customizationId),
});
}
- /** Global customizations are those that affect parts of the GUI other than
- * the modes. They include things like settings for the search screen.
- * Reset does NOT clear global customizations.
- */
- getGlobalCustomization(id: string, defaultValue?: Customization): Customization | void {
- return this.transform(
- this.globalCustomizations.get(id) ?? this.defaultCustomizations.get(id) ?? defaultValue
- );
+ private setGlobalCustomization(id: string, value: Customization): void {
+ const defaultCustomization = this.defaultCustomizations.get(id);
+ const globCustomization = this.globalCustomizations.get(id);
+
+ const sourceCustomization = this._cloneIfNeeded(globCustomization) || defaultCustomization;
+ this.globalCustomizations.set(id, this._update(sourceCustomization, value));
+
+ 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
- * the old one. This only works if you run once for the merge, so in general,
- * the source value should be global, while the appends should be mode based.
- * However, you can append to a global value too, as long as you ensure it
- * only gets merged once.
+ * Registers a custom command to be used in customization updates.
+ * @param commandName - The name of the command (without the $ prefix)
+ * it will be prefixed with $
+ * @param handler - Function that handles the command it receives the value and the original value
*/
- private mergeValue(oldValue, newValue, mergeType = MergeEnum.Replace) {
- if (mergeType === MergeEnum.Replace) {
+ public registerCustomUpdateCommand(
+ 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;
}
- const returnValue = mergeWith(
- {},
- oldValue,
- newValue,
- mergeType === MergeEnum.Append ? appendCustomizer : mergeCustomizer
- );
- return returnValue;
+ const result = update(oldValue, newValue);
+ return result;
}
- public setGlobalCustomization(id: string, value: Customization, merge = MergeEnum.Replace): void {
- const defaultCustomization = this.defaultCustomizations.get(id);
- const globCustomization = this.globalCustomizations.get(id);
- const sourceCustomization =
- (globCustomization && cloneDeepWith(globCustomization, cloneCustomizer)) ||
- defaultCustomization ||
- {};
- this.globalCustomizations.set(
- id,
- this.mergeValue(sourceCustomization, value, value.merge ?? merge)
- );
- this.transformedCustomizations.clear();
- this._broadcastGlobalCustomizationModified();
- }
-
- public setDefaultCustomization(
- id: string,
- value: Customization,
- merge = MergeEnum.Replace
- ): void {
- if (this.defaultCustomizations.has(id)) {
- throw new Error(`Trying to update existing default for customization ${id}`);
+ private _cloneIfNeeded(value: any) {
+ // If it's null/undefined or not an object, return as is
+ if (!value || typeof value !== 'object') {
+ return value;
}
- this.transformedCustomizations.clear();
- this.defaultCustomizations.set(
- id,
- this.mergeValue(this.defaultCustomizations.get(id), value, merge)
- );
+
+ // If it's an array, create a shallow copy
+ if (Array.isArray(value)) {
+ return [...value];
+ }
+
+ // Otherwise create a shallow copy of the object
+ return { ...value };
}
- protected setConfigGlobalCustomization(configuration: AppConfigCustomization): void {
- this.globalCustomizations.clear();
- 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 {
- 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 {
+ _addReference(value?: any, type = CustomizationScope.Global): void {
if (!value) {
return;
}
+
if (typeof value === 'string') {
- const extensionValue = this.findExtensionValue(value);
- // The child of a reference is only a set of references when an array,
- // 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);
+ const extensionValue = this._findExtensionValue(value);
+ value = extensionValue.value;
}
+
+ 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 customization.
*/
- addReferences(references?, type = CustomizationType.Global): void {
+ addReferences(references?: any, type = CustomizationScope.Global): void {
if (!references) {
return;
}
if (Array.isArray(references)) {
references.forEach(item => {
- this.addReference(item, type);
+ this._addReference(item, type);
});
} else {
- for (const key of Object.keys(references)) {
- const value = references[key];
- this.addReference(value, type, key);
- }
+ this._addReference(references, type);
}
}
}
-/**
- * Custom merging function, to handle merging arrays and copying functions
- */
-function appendCustomizer(obj, src) {
- if (Array.isArray(obj)) {
- const srcArray = Array.isArray(src);
- 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;
+/** Add custom $filter command */
+extend('$filter', (query, original) => {
+ // This helper checks if an object matches all key/value pairs in `match`
+ function objectMatches(item, matchObj) {
+ return (
+ item && typeof item === 'object' && Object.entries(matchObj).every(([k, v]) => item[k] === v)
+ );
+ }
+
+ // Recursively walk objects/arrays. Whenever we hit an array, we either filter
+ // 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);
+ }
+
+ // 2) If it's a string, remove items whose .id matches that string
+ if (typeof filterQuery === 'string') {
+ return value.filter(item => item.id !== filterQuery);
+ }
+
+ // 3) If it's an object with .match and .merge, apply the merge to matched items
+ if (typeof filterQuery === 'object' && filterQuery.match && filterQuery.$merge) {
+ // First recurse into sub-objects/arrays so we handle deeply nested arrays
+ result = value.map(item => deepFilter(item, filterQuery));
+ // Then update items that match
+ return result.map(item => {
+ if (objectMatches(item, filterQuery.match)) {
+ return { ...item, ...filterQuery.$merge };
}
- } else {
- newList.splice(position, 0, value);
- }
+ return item;
+ });
}
- return newList;
+
+ // 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));
}
- return obj.concat(src);
- }
- return cloneCustomizer(src);
-}
-function mergeCustomizer(obj, src) {
- return cloneCustomizer(src);
-}
-
-function findPosition(key, value, newList) {
- const numVal = Number(key);
- const isNumeric = !isNaN(numVal);
- const { length: len } = newList;
-
- if (isNumeric) {
- if (newList[numVal < 0 ? numVal + len : numVal]) {
- return { isMerge: true, position: (numVal + len) % len };
+ // 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;
}
- 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 };
-}
-/**
- * Custom cloning function to just copy function reference
- */
-function cloneCustomizer(value) {
- if (typeof value === 'function') {
+ // If it's neither array nor object, just return it
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;
}
diff --git a/platform/core/src/services/CustomizationService/types.ts b/platform/core/src/services/CustomizationService/types.ts
index da607dec6..94be1cef4 100644
--- a/platform/core/src/services/CustomizationService/types.ts
+++ b/platform/core/src/services/CustomizationService/types.ts
@@ -4,8 +4,8 @@ import { ComponentType } from 'react';
export type Obj = Record;
export interface BaseCustomization extends Obj {
- id: string;
- customizationType?: string;
+ id?: string;
+ inheritsFrom?: string;
description?: string;
label?: string;
commands?: Command[];
diff --git a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts
index d5f60dd4b..6a1692ce8 100644
--- a/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts
+++ b/platform/core/src/services/HangingProtocolService/HangingProtocolService.ts
@@ -434,7 +434,9 @@ export default class HangingProtocolService extends PubSubService {
this._setProtocol(matchedProtocol);
}
- this._commandsManager.run(this.protocol?.callbacks?.onProtocolEnter);
+ if (this.protocol?.callbacks?.onProtocolEnter) {
+ this._commandsManager.run(this.protocol?.callbacks?.onProtocolEnter);
+ }
}
/**
@@ -1028,7 +1030,9 @@ export default class HangingProtocolService extends PubSubService {
// before reassigning the protocol, we need to check if there is a callback
// on the old protocol that needs to be called
// 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;
diff --git a/platform/docs/docs/assets/img/colorbarImage.png b/platform/docs/docs/assets/img/colorbarImage.png
new file mode 100644
index 000000000..f862211c4
Binary files /dev/null and b/platform/docs/docs/assets/img/colorbarImage.png differ
diff --git a/platform/docs/docs/assets/img/layoutSelectorAdvancedPresetGeneratorImage.png b/platform/docs/docs/assets/img/layoutSelectorAdvancedPresetGeneratorImage.png
new file mode 100644
index 000000000..6a912d77c
Binary files /dev/null and b/platform/docs/docs/assets/img/layoutSelectorAdvancedPresetGeneratorImage.png differ
diff --git a/platform/docs/docs/assets/img/layoutSelectorCommonPresetsImage.png b/platform/docs/docs/assets/img/layoutSelectorCommonPresetsImage.png
new file mode 100644
index 000000000..57bf60ab1
Binary files /dev/null and b/platform/docs/docs/assets/img/layoutSelectorCommonPresetsImage.png differ
diff --git a/platform/docs/docs/assets/img/measurement-labels-auto.png b/platform/docs/docs/assets/img/measurement-labels-auto.png
new file mode 100644
index 000000000..619608429
Binary files /dev/null and b/platform/docs/docs/assets/img/measurement-labels-auto.png differ
diff --git a/platform/docs/docs/assets/img/segDisplayEditingFalse.png b/platform/docs/docs/assets/img/segDisplayEditingFalse.png
new file mode 100644
index 000000000..a6d130621
Binary files /dev/null and b/platform/docs/docs/assets/img/segDisplayEditingFalse.png differ
diff --git a/platform/docs/docs/assets/img/segDisplayEditingTrue.png b/platform/docs/docs/assets/img/segDisplayEditingTrue.png
new file mode 100644
index 000000000..993f23f3b
Binary files /dev/null and b/platform/docs/docs/assets/img/segDisplayEditingTrue.png differ
diff --git a/platform/docs/docs/assets/img/segmentationShowAddSegmentImage.png b/platform/docs/docs/assets/img/segmentationShowAddSegmentImage.png
new file mode 100644
index 000000000..f53c75a80
Binary files /dev/null and b/platform/docs/docs/assets/img/segmentationShowAddSegmentImage.png differ
diff --git a/platform/docs/docs/assets/img/segmentationTableModeImage.png b/platform/docs/docs/assets/img/segmentationTableModeImage.png
new file mode 100644
index 000000000..22f952621
Binary files /dev/null and b/platform/docs/docs/assets/img/segmentationTableModeImage.png differ
diff --git a/platform/docs/docs/assets/img/segmentationTableModeImage2.png b/platform/docs/docs/assets/img/segmentationTableModeImage2.png
new file mode 100644
index 000000000..c0de2b70f
Binary files /dev/null and b/platform/docs/docs/assets/img/segmentationTableModeImage2.png differ
diff --git a/platform/docs/docs/assets/img/seriesSort.png b/platform/docs/docs/assets/img/seriesSort.png
new file mode 100644
index 000000000..f325392b5
Binary files /dev/null and b/platform/docs/docs/assets/img/seriesSort.png differ
diff --git a/platform/docs/docs/assets/img/studyMenuItemsImage.png b/platform/docs/docs/assets/img/studyMenuItemsImage.png
new file mode 100644
index 000000000..b70a44d79
Binary files /dev/null and b/platform/docs/docs/assets/img/studyMenuItemsImage.png differ
diff --git a/platform/docs/docs/assets/img/thumbnailMenuItemsImage.png b/platform/docs/docs/assets/img/thumbnailMenuItemsImage.png
new file mode 100644
index 000000000..135433748
Binary files /dev/null and b/platform/docs/docs/assets/img/thumbnailMenuItemsImage.png differ
diff --git a/platform/docs/docs/assets/img/viewportOverlay-customization.png b/platform/docs/docs/assets/img/viewportOverlay-customization.png
new file mode 100644
index 000000000..7f7245c35
Binary files /dev/null and b/platform/docs/docs/assets/img/viewportOverlay-customization.png differ
diff --git a/platform/docs/docs/assets/img/windowLevelPresets.png b/platform/docs/docs/assets/img/windowLevelPresets.png
new file mode 100644
index 000000000..f98a282fc
Binary files /dev/null and b/platform/docs/docs/assets/img/windowLevelPresets.png differ
diff --git a/platform/docs/docs/migration-guide/3p8-to-3p9/8-Refactorings.md b/platform/docs/docs/migration-guide/3p8-to-3p9/8-Refactorings.md
index 795b3e3c6..c90717d88 100644
--- a/platform/docs/docs/migration-guide/3p8-to-3p9/8-Refactorings.md
+++ b/platform/docs/docs/migration-guide/3p8-to-3p9/8-Refactorings.md
@@ -67,12 +67,12 @@ customizationService.addModeCustomizations([
customizationService.addModeCustomizations([
// To disable editing in the SegmentationTable
{
- id: 'PanelSegmentation.disableEditing',
+ id: 'panelSegmentation.disableEditing',
disableEditing: true,
},
// To disable editing in the MeasurementTable
{
- id: 'PanelMeasurement.disableEditing',
+ id: 'panelMeasurement.disableEditing',
disableEditing: true,
},
])
@@ -106,11 +106,11 @@ customizationService.addModeCustomizations([
```js
customizationService.addModeCustomizations([
{
- id: 'PanelSegmentation.tableMode',
+ id: 'panelSegmentation.tableMode',
mode: 'expanded',
},
{
- id: 'PanelSegmentation.onSegmentationAdd',
+ id: 'panelSegmentation.onSegmentationAdd',
onSegmentationAdd: () => {
commandsManager.run('createNewLabelmapFromPT');
},
diff --git a/platform/docs/docs/migration-guide/3p9-to-3p10/CustomizationService/index.md b/platform/docs/docs/migration-guide/3p9-to-3p10/CustomizationService/index.md
new file mode 100644
index 000000000..e0d3fdb0e
--- /dev/null
+++ b/platform/docs/docs/migration-guide/3p9-to-3p10/CustomizationService/index.md
@@ -0,0 +1,239 @@
+---
+sidebar_position: 2
+title: Customization Service
+---
+
+# CustomizationService
+
+
+
+**Key Changes:**
+
+
+1. **Unified Customization Getter:**
+ - The `getCustomization` method now uniformly retrieves customizations, prioritizing `global`, then `mode`, and finally `default` customizations.
+ - The `defaultValue` parameter in `getCustomization` is no longer used for setting defaults. It simply returns if no customization is found.
+ - The methods `getModeCustomization` and `getGlobalCustomization` are deprecated.
+
+2. **Simplified Customization Registration:**
+ - The `customizationType` property in customization definitions is renamed to `inheritsFrom`.
+ - The `merge` property in customization definitions is removed. Instead, a customization is merged using the helper methods. The basic update commands are listed in the table below, and you can learn more about the helper methods [here](../../../platform/services/customization-service/customizationService.md).
+
+ | Command | Description | Example |
+ | :------- | :---------------------------------------- | :------------------------------------------------ |
+ | `$set` | Replace a value entirely | Replace a list or object |
+ | `$push` | Append items to an array | Add to the end of a list |
+ | `$unshift` | Prepend items to an array | Add to the start of a list |
+ | `$splice` | Insert, remove, or replace at specific index | Modify specific indices in a list |
+ | `$merge` | Update specific fields in an object | Change a subset of fields |
+ | `$apply` | Compute the new value dynamically | Apply a function to transform values |
+ | `$filter` | Find and update specific items in arrays | Target nested structures based on matching criteria |
+
+
+3. **New `$transform` command:**
+ - If you were using the `transform` command, you should now use the `$transform` command. Just a simple rename to make it more consistent with the other commands.
+
+
+5. **Renamed `CornerstoneOverlay` customizations:**
+ - The `cornerstoneOverlay` customizations (`cornerstoneOverlayTopLeft`, `cornerstoneOverlayTopRight`, `cornerstoneOverlayBottomLeft`, `cornerstoneOverlayBottomRight`) have been renamed to `viewportOverlay.topLeft`, `viewportOverlay.topRight`, `viewportOverlay.bottomLeft`, and `viewportOverlay.bottomRight`. See dedicated page for customizing viewport overlays [here](../../../platform/services/customization-service/viewportOverlay.md).
+
+6. **Renamed `customRoutes`:**
+ - The `customRoutes` customization is renamed to `routes.customRoutes`.
+
+7. **`contextMenu` customization:**
+ - The `contextMenu` customization now uses the `inheritsFrom` property to inherit from other context menus, previously it was called `customizationType`
+
+8. **New `immutability-helper` dependency:**
+ The `immutability-helper` library is now used for merging customizations. If you encounter an error related to it, you'll need to install it - though OHIF should really handle the installation for you, so this is pretty much just a heads up.
+
+**Migration Steps:**
+
+1. **Replace `getModeCustomization` and `getGlobalCustomization` with `getCustomization`:**
+
+ - **Before:**
+
+ ```javascript
+ const tools = customizationService.getModeCustomization(
+ 'cornerstone.overlayViewportTools'
+ )?.tools;
+ const globalValue = customizationService.getGlobalCustomization('someGlobalKey');
+ ```
+
+ - **After:**
+
+ ```javascript
+ const tools = customizationService.getCustomization('cornerstone.overlayViewportTools');
+ const globalValue = customizationService.getCustomization('someGlobalKey');
+ ```
+
+
+ :::note
+ The returned value is the actual customization value, not an object that needs to be broken down.
+ :::
+
+2. **Update Customization Definitions:**
+ - We've moved away from using random items in the customization definition, and now we use the `id` property to identify the customization as a value. Previously, it was referred to as `value`, `values`, and so on, but now an `id` is used to reference the customization. This approach really simplifies things - when you need to grab the customization, you can just use the `id` to get it, and you don't have to bother with destructuring the value from the object.
+
+
+
+
+**Example: Customizing a Panel**
+
+**Before (v3.9):**
+
+```javascript
+// the default value was hardcoded inside the panel itself - bad idea!
+// default was given in the panel itself
+
+// PanelSegmentation.tsx
+
+// Retrieve the onSegmentationAdd customization
+const { onSegmentationAdd } = customizationService.getCustomization(
+ 'PanelSegmentation.onSegmentationAdd',
+ {
+ id: 'segmentation.onSegmentationAdd',
+ onSegmentationAdd: handlers.onSegmentationAdd,
+ }
+);
+
+// Retrieve the disableEditing customization
+const { disableEditing } = customizationService.getCustomization(
+ 'PanelSegmentation.disableEditing',
+ {
+ id: 'default.disableEditing',
+ disableEditing: false,
+ }
+);
+
+
+
+// mode was customizing it via
+customizationService.addModeCustomizations([
+ {
+ id: 'PanelSegmentation.tableMode',
+ mode: 'expanded',
+ },
+ {
+ id: 'PanelSegmentation.showAddSegment',
+ showAddSegment: false,
+ },
+]);
+
+```
+
+**After (v3.10):**
+
+```javascript
+// cornerstone extension getCustomizationModule
+// centralized customization location for all extensions - good!
+function getCustomizationModule() {
+ return [
+ {
+ name: 'default',
+ value: {
+ 'panelSegmentation.disableEditing': false,
+ 'panelSegmentation.showAddSegment': true,
+ },
+ },
+ ];
+}
+
+
+// inside panelSegmentation.tsx
+const disableEditing = customizationService.getCustomization('panelSegmentation.disableEditing');
+const showAddSegment = customizationService.getCustomization('panelSegmentation.showAddSegment');
+
+
+// mode can customize it via $ operators for mode customizations
+customizationService.setCustomizations({
+ 'panelSegmentation.disableEditing': { $set: true },
+ 'panelSegmentation.showAddSegment': { $set: false },
+});
+
+
+//or via configuration for global customizations
+window.config = {
+ // rest of config
+ customizationService: [
+ {
+ 'panelSegmentation.disableEditing': {
+ $set: true, // Disables editing of segmentations in the panel
+ },
+ },
+ ],
+ // rest of config
+};
+```
+
+
+
+**Example: Updating a Customization**
+
+Let's say you have a customization in v3.9 that adds a custom overlay item to the top-left corner:
+
+**Before (v3.9):**
+
+```javascript
+// In your mode's onModeEnter
+customizationService.addModeCustomizations([
+ {
+ id: 'cornerstoneOverlayTopLeft',
+ items: [
+ {
+ id: 'myCustomOverlay',
+ customizationType: 'ohif.overlayItem',
+ attribute: 'PatientName',
+ label: 'Patient:',
+ },
+ ],
+ },
+]);
+```
+
+**After (v3.10):**
+
+```javascript
+// In your mode's onModeEnter or elsewhere
+customizationService.setCustomizations({
+ 'viewportOverlay.topLeft': {
+ $push: [
+ {
+ id: 'myCustomOverlay',
+ inheritsFrom: 'ohif.overlayItem',
+ attribute: 'PatientName',
+ label: 'Patient:',
+ },
+ ],
+ },
+});
+```
+
+**Note:**
+
+- The `customizationType` is replaced with `inheritsFrom`.
+
+
+
+
+
+## Renaming
+
+To keep our customization system consistent, you should be aware of a few key renaming conventions. We now follow a straightforward naming convention for customizations: `scopeName.customizationItem`.
+
+
+
+| Customization Key (Old) | Customization Key (New) | Description |
+| :------------------------------------------ | :------------------------------------------- | :-------------------------------------------------------------------------- |
+| `PanelMeasurement.disableEditing` | `panelMeasurement.disableEditing` | Disables editing measurements in the Measurement Panel and after SR hydration. |
+| `PanelSegmentation.CustomDropdownMenuContent` | `panelSegmentation.customDropdownMenuContent` | Custom content for the dropdown menu in the Segmentation Panel. |
+| `PanelSegmentation.disableEditing` | `panelSegmentation.disableEditing` | Disables editing segmentations in the Segmentation Panel. |
+| `PanelSegmentation.showAddSegment` | `panelSegmentation.showAddSegment` | Controls visibility of the "Add Segment" button in the Segmentation Panel. |
+| `PanelSegmentation.onSegmentationAdd` | `panelSegmentation.onSegmentationAdd` | Custom function to execute when a new segmentation is added. |
+| `PanelSegmentation.tableMode` | `panelSegmentation.tableMode` | Controls the table mode (collapsed/expanded) in the Segmentation Panel. |
+| `PanelSegmentation.readableText` | `panelSegmentation.readableText` | Custom readable text labels for the Segmentation Panel. |
+| `PanelStudyBrowser.studyMode` | `studyBrowser.studyMode` | Controls the study mode (all/primary/recent) in the Study Browser Panel. |
+| `customRoutes` | `routes.customRoutes` | Defines custom routes for the application. |
+| `cornerstoneOverlayTopLeft` | `viewportOverlay.topLeft` | Custom overlay items for the top-left corner of the viewport. |
+| `cornerstoneOverlayTopRight` | `viewportOverlay.topRight` | Custom overlay items for the top-right corner of the viewport. |
+| `cornerstoneOverlayBottomLeft` | `viewportOverlay.bottomLeft` | Custom overlay items for the bottom-left corner of the viewport. |
+| `cornerstoneOverlayBottomRight` | `viewportOverlay.bottomRight` | Custom overlay items for the bottom-right corner of the viewport. |
diff --git a/platform/docs/docs/migration-guide/3p9-to-3p10/General/index.md b/platform/docs/docs/migration-guide/3p9-to-3p10/General/index.md
index 0b38da7cb..6ef59a907 100644
--- a/platform/docs/docs/migration-guide/3p9-to-3p10/General/index.md
+++ b/platform/docs/docs/migration-guide/3p9-to-3p10/General/index.md
@@ -1,4 +1,5 @@
---
+sidebar_position: 1
title: General
---
diff --git a/platform/docs/docs/platform/services/customization-service/Measurements.md b/platform/docs/docs/platform/services/customization-service/Measurements.md
new file mode 100644
index 000000000..7d8a69801
--- /dev/null
+++ b/platform/docs/docs/platform/services/customization-service/Measurements.md
@@ -0,0 +1,9 @@
+---
+title: Measurements
+---
+
+
+
+import { measurementsCustomizations, TableGenerator } from './sampleCustomizations';
+
+{TableGenerator(measurementsCustomizations)}
diff --git a/platform/docs/docs/platform/services/customization-service/Segmentation.md b/platform/docs/docs/platform/services/customization-service/Segmentation.md
new file mode 100644
index 000000000..7953b39b3
--- /dev/null
+++ b/platform/docs/docs/platform/services/customization-service/Segmentation.md
@@ -0,0 +1,9 @@
+---
+title: Segmentation
+---
+
+
+
+import { segmentationCustomizations, TableGenerator } from './sampleCustomizations';
+
+{TableGenerator(segmentationCustomizations)}
diff --git a/platform/docs/docs/platform/services/customization-service/StudyBrowser.md b/platform/docs/docs/platform/services/customization-service/StudyBrowser.md
new file mode 100644
index 000000000..fbbf3830e
--- /dev/null
+++ b/platform/docs/docs/platform/services/customization-service/StudyBrowser.md
@@ -0,0 +1,11 @@
+---
+title: Study Browser
+---
+
+# Study Browser
+
+The Study Browser is a component that allows users to browse and manage studies.
+
+import { studyBrowserCustomizations, TableGenerator } from './sampleCustomizations';
+
+{TableGenerator(studyBrowserCustomizations)}
diff --git a/platform/docs/docs/platform/services/customization-service/_category_.json b/platform/docs/docs/platform/services/customization-service/_category_.json
new file mode 100644
index 000000000..710e1087f
--- /dev/null
+++ b/platform/docs/docs/platform/services/customization-service/_category_.json
@@ -0,0 +1,4 @@
+{
+ "label": "Customization Service",
+ "position": 4
+}
diff --git a/platform/docs/docs/platform/services/customization-service/advanced.md b/platform/docs/docs/platform/services/customization-service/advanced.md
new file mode 100644
index 000000000..7efe64df9
--- /dev/null
+++ b/platform/docs/docs/platform/services/customization-service/advanced.md
@@ -0,0 +1,98 @@
+---
+title: Advanced Customization
+---
+
+
+Below is an overview of how `transform` and `inheritsFrom` work within this customization system. They allow you to build a hierarchy of customizations in which items can inherit fields from a parent and then optionally apply a transformation before returning the final result.
+
+
+## `inheritsFrom`
+
+### Purpose
+Indicates that the current customization should inherit and merge fields from another customization. The system fetches the parent customization, merges its properties, and returns a combined object.
+
+### How It Works
+1. When you request or transform a customization that has `inheritsFrom: "parentCustomizationId"`, the service looks up `parentCustomizationId` via `getCustomization(...)`.
+2. Properties from the parent get copied into the child, but the child’s own properties overwrite any matching ones from the parent.
+3. If the child has a `transform` function, it runs after the merge.
+
+### Example
+```js
+export default {
+ measurementsContextMenu: {
+ $set: {
+ inheritsFrom: 'ohif.contextMenu',
+ menus: [
+ {
+ selector: ({ nearbyToolData }) => !!nearbyToolData,
+ items: [
+ // ...
+ ],
+ },
+ ],
+ },
+ },
+};
+```
+Here, `measurementsContextMenu` inherits from `ohif.contextMenu`. During retrieval or transformation, the system merges `ohif.contextMenu` into `measurementsContextMenu`.
+
+---
+
+## `transform`
+
+### Purpose
+Specifies a function that can modify or enhance the customization object at runtime. Often used to run extra setup code or combine fields in a special way.
+
+### How It Works
+1. You define a `transform(customizationService)` function inside your customization object.
+2. When the system retrieves the customization, after merging any inherited fields, it calls `transform`.
+3. The function may return an updated object, clone existing properties, or apply logic to nested items.
+
+### Example
+```js
+export default {
+ '@ohif/contextMenuAnnotationCode': {
+ $transform: function (customizationService) {
+ const { code: codeRef } = this;
+ if (!codeRef) {
+ throw new Error(`item ${this} has no code ref`);
+ }
+ const codingValues = customizationService.getCustomization('codingValues');
+ const code = codingValues[codeRef];
+
+ return {
+ ...this,
+ codeRef,
+ code: { ref: codeRef, ...code },
+ label: this.label || code.text || codeRef,
+ commands: [{ commandName: 'updateMeasurement' }],
+ };
+ },
+ },
+};
+```
+In this snippet, the `transform` function:
+- Reads a code reference from `this`.
+- Looks up more data for that code in `codingValues`.
+- Merges those details back into `this` before returning the final object.
+
+---
+
+## Common Use Cases
+
+1. **Base and Specialized Customizations**
+ Use `inheritsFrom` to define a broad, general customization (e.g., a generic context menu) and then create specialized versions that only override certain fields.
+
+2. **Dynamic Assembly**
+ Use `transform` when you need to compute or modify fields based on application state or other registered customizations.
+
+3. **Nested Items**
+ If an item within the customization also has `inheritsFrom`, it will follow the same inheritance flow and can run its own `transform` logic.
+
+---
+
+**Key Points**
+- `inheritsFrom` is a reference to another customization’s ID.
+- If `transform` is defined, it always runs after inheritance is resolved.
+- Merging is shallow: child properties override the parent’s.
+- You can nest multiple levels of inheritance, each possibly having its own `transform` step.
diff --git a/platform/docs/docs/platform/services/customization-service/contextMenu.md b/platform/docs/docs/platform/services/customization-service/contextMenu.md
new file mode 100644
index 000000000..345531087
--- /dev/null
+++ b/platform/docs/docs/platform/services/customization-service/contextMenu.md
@@ -0,0 +1,21 @@
+---
+sidebar_label: Context Menu
+sidebar_position: 3
+---
+
+# Context Menu
+
+
+
+
+
+Context menus can be created by defining the menu structure and click
+interaction, as defined in the `ContextMenu/types`. There are examples
+below specific to the cornerstone context, because the actual click
+handler and attributes used to decide when and how to display the menu
+are specific to the context used for where the menu is displayed.
+
+## Cornerstone Context Menu
+
+The default cornerstone context menu can be customized by setting the
+`cornerstoneContextMenu`. For a full example, see `findingsContextMenu`.
diff --git a/platform/docs/docs/platform/services/customization-service/customRoutes.md b/platform/docs/docs/platform/services/customization-service/customRoutes.md
new file mode 100644
index 000000000..cc52cae6c
--- /dev/null
+++ b/platform/docs/docs/platform/services/customization-service/customRoutes.md
@@ -0,0 +1,83 @@
+---
+sidebar_label: Custom Routes
+sidebar_position: 2
+---
+
+# customRoutes
+
+* Name: `routes.customRoutes` global
+* Attributes:
+** `routes` of type List of route objects (see `route/index.tsx`) is a set of route objects to add.
+** Should any element of routes match an existing baked in element, the baked in one will be replaced.
+** `notFoundRoute` is the route to display when nothing is found (this has to be at the end of the overall list, so can't be added to routes)
+
+### Example
+
+Since custom routes use React, they should be defined as modules inside the extension that is providing them. And cannot be
+in the AppConfig (yet).
+
+
+```js
+export default function getCustomizationModule({ servicesManager, extensionManager }) {
+ return [
+ {
+ name: 'helloPage',
+ value: {
+ 'routes.customRoutes': {
+ routes: {
+ $push: [
+ {
+ path: '/custom',
+ children: () =>
Hello Custom Route
,
+ },
+ ],
+ },
+ },
+ },
+ },
+ ]
+```
+
+Then after you define the module, you can add it to the customizationService in the AppConfig and reference it by the name you provided.
+
+```js
+customizationService: [
+ // Shows a custom route -access via http://localhost:3000/custom
+ '@ohif/extension-default.customizationModule.helloPage',
+],
+```
+
+You can provide multiple custom routes in the same module, for instance another extension can also push to the routes array.
+
+```js
+export default function getCustomizationModule({ servicesManager, extensionManager }) {
+ return [
+ {
+ name: 'secondPage',
+ value: {
+ customRoutes: {
+ routes: {
+ $push: [
+ {
+ path: '/second',
+ children: () =>
Hello Second Route
,
+ },
+ ],
+ },
+ },
+ },
+ },
+ ]
+}
+```
+
+Then you can add it to the customizationService in the AppConfig and reference it by the name you provided.
+
+```js
+customizationService: [
+ // Shows a custom route -access via http://localhost:3000/custom
+ '@ohif/extension-default.customizationModule.helloPage',
+ // Shows a custom route -access via http://localhost:3000/second
+ '@ohif/extension-default.customizationModule.secondPage',
+],
+```
diff --git a/platform/docs/docs/platform/services/customization-service/customizationService.md b/platform/docs/docs/platform/services/customization-service/customizationService.md
new file mode 100644
index 000000000..cda486984
--- /dev/null
+++ b/platform/docs/docs/platform/services/customization-service/customizationService.md
@@ -0,0 +1,544 @@
+---
+sidebar_label: Introduction
+sidebar_position: 1
+---
+
+import { customizations, TableGenerator } from './sampleCustomizations';
+import Heading from '@theme/Heading';
+import TOCInline from '@theme/TOCInline';
+
+# Customization Service
+
+There are a lot of places where users may want to configure certain elements
+differently between different modes or for different deployments. A mode
+example might be the use of a custom overlay showing mode related DICOM header
+information such as radiation dose or patient age.
+
+The use of `customizationService` enables these to be defined in a typed fashion by
+providing an easy way to set default values for this, but to allow a
+non-default value to be specified by the configuration or mode.
+
+
+:::note
+
+`customizationService` itself doesn't implement the actual customization,
+but rather just provide mechanism to register reusable prototypes, to configure
+those prototypes with actual configurations, and to use the configured objects
+(components, data, whatever).
+
+Actual implementation of the customization is totally up to the component that
+supports customization.
+:::
+
+
+## General Overview
+
+This framework allows you to configure many features, or "slots," through customization modules. Extensions can choose to offer their own module, which outlines which values can be changed. By looking at each extension's getCustomizationModule(), you can see which objects or components are open to customization.
+
+Below is a high-level example of how you might define a default customization and then consume and override it:
+
+1. **Defining a Customizable Default**
+
+ In your extension, you might export a set of default configurations (for instance, a list that appears in a panel). Here, you provide an identifier and store the default list under that identifier. This makes the item discoverable by the customization service:
+
+ ```js
+ // Inside your extension’s customization module
+ export default function getCustomizationModule() {
+ return [
+ {
+ name: 'default',
+ value: {
+ defaultList: ['Item A', 'Item B'],
+ },
+ },
+ ];
+ }
+ ```
+
+ By naming it `default`, it is automatically registered.
+
+ :::info
+ You might want to have customizations ready to use in your application without actually applying them. In such cases, you can name them something other than `default`. For example, in your mode, you can do this:
+
+ ```js
+ customizationService.setCustomizations([
+ '@ohif/extension-cornerstone-dicom-seg.customizationModule.dicom-seg-sorts',
+ ]);
+ ```
+
+ This is really useful when you want to apply a set of customizations as a pack, kind of like a bundle.
+ :::
+
+3. **Retrieving the Default Customization**
+ In the panel or component (or whatever) that needs the list, you retrieve it using `getCustomization`:
+
+ ```js
+ const myList = customizationService.getCustomization('defaultList');
+ // If unmodified, this returns ['Item A', 'Item B']
+ ```
+
+ This allows your component to always fetch the most current version (original default or overridden).
+
+4. **Overriding from Outside**
+ To customize this list outside your extension, call `setCustomizations` with the identifier (`'defaultList'`). For example, a mode can modify the list to add or change items:
+
+ ```js
+ // From within a mode (or globally)
+ customizationService.setCustomizations({
+ 'defaultList': {
+ $set: ['New Item 1', 'New Item 2'],
+ },
+ });
+ ```
+
+ The next time any panel calls `getCustomization('defaultList')`, it will get the updated list.
+
+ Don't worry we will go over the `$set` syntax in more detail later.
+
+---
+
+## Scope of Customization
+
+
+Customizations can be declared at three different scopes, each with its own priority and lifecycle. These scopes determine how and when customizations are applied.
+
+
+### 1. **Default Scope**
+ - **Purpose**: Establish baseline or "fallback" values that extensions provide.
+ - **Options**:
+ 1. **Via Extensions**:
+ - Implement a `getCustomizationModule` function in your extension and name it `default`.
+ ```tsx
+ function getCustomizationModule() {
+ return [
+ {
+ name: 'default',
+ value: {
+ 'studyBrowser.sortFunctions': {
+ $set: [
+ {
+ label: 'Default Sort Function',
+ sortFunction: (a, b) => a.SeriesDate - b.SeriesDate,
+ },
+ ],
+ },
+ },
+ },
+ ];
+ }
+ ```
+ 2. **Using the `setCustomizations` Method**:
+ - Call `setCustomizations` in your application and specify `CustomizationScope.Default` as the second argument:
+ ```tsx
+ customizationService.setCustomizations(
+ {
+ 'studyBrowser.sortFunctions': {
+ $set: [
+ {
+ label: 'Default Sort Function',
+ sortFunction: (a, b) => a.SeriesDate - b.SeriesDate,
+ },
+ ],
+ },
+ },
+ CustomizationScope.Default
+ );
+ ```
+
+
+### 2. **Mode Scope**
+ - **Purpose**: Apply customizations specific to a particular mode.
+ - **Lifecycle**: These customizations are cleared or reset when switching between modes.
+ - **Example**: Use the `setCustomizations` method to define mode-specific behavior.
+ ```tsx
+ customizationService.setCustomizations({
+ 'studyBrowser.sortFunctions': {
+ $set: [
+ {
+ label: 'Mode-Specific Sort Function',
+ sortFunction: (a, b) => b.SeriesDate - a.SeriesDate,
+ },
+ ],
+ },
+ });
+ ```
+
+
+
+### 3. **Global Scope**
+ - **Purpose**: Apply system-wide customizations that override both default and mode-scoped values.
+ - **How to Configure**:
+ 1. Add global customizations directly to the application's configuration file:
+ ```jsx
+ window.config = {
+ name: 'config/default.js',
+ routerBasename: '/',
+ customizationService: [
+ {
+ 'studyBrowser.sortFunctions': {
+ $push: [
+ {
+ label: 'Global Sort Function',
+ sortFunction: (a, b) => b.SeriesDate - a.SeriesDate,
+ },
+ ],
+ },
+ },
+ ],
+ };
+ ```
+
+ 2. Use Namespaced Extensions:
+ - Instead of directly specifying customizations in the configuration, you can refer to a predefined customization module within an extension:
+
+ ```jsx
+ window.config = {
+ name: 'config/default.js',
+ routerBasename: '/',
+ customizationService: [
+ '@ohif/extension-cornerstone.customizationModule.newCustomization',
+ ],
+ };
+ ```
+
+ - In this example, the `newCustomization` module within the `@ohif/extension-cornerstone` extension contains the global customizations. The application will load and apply these settings globally.
+
+ ```tsx
+ function getCustomizationModule() {
+ return [
+ {
+ name: 'newCustomization',
+ value: {
+ 'studyBrowser.sortFunctions': {
+ $push: [
+ {
+ label: 'Global Namespace Sort Function',
+ sortFunction: (a, b) => b.SeriesDate - a.SeriesDate,
+ },
+ ],
+ },
+ },
+ },
+ ];
+ }
+ ```
+
+
+### Priority of Scopes
+When a customization is retrieved:
+1. **Global Scope**: Takes precedence if defined.
+2. **Mode Scope**: Used if no global customization is defined.
+3. **Default Scope**: Fallback when neither global nor mode-specific values are available.
+
+
+As you have guessed the `.setCustomizations` accept a second argument which is the scope. By default it is set to `mode`.
+
+
+## Customization Syntax
+
+
+The customization syntax is designed to offer **flexibility** when modifying configurations. Instead of simply replacing values, you can perform granular updates like appending items to arrays, inserting at specific indices, updating deeply nested fields, or applying filters. This flexibility ensures that updates are efficient, targeted, and suitable for complex data structures.
+
+
+
+Why a Special Syntax?
+
+
+Traditional value replacement might not be ideal in scenarios such as:
+- **Appending or prepending** to an existing list instead of overwriting it.
+- **Selective updates** for specific fields in an object without affecting other fields.
+- **Filtering or merging** nested items in arrays or objects while preserving other parts.
+
+To address these needs, the customization service uses a **special syntax** inspired by [immutability-helper](https://github.com/kolodny/immutability-helper) commands. Below are examples of each operation.
+
+
+
+---
+
+### 1. Replace a Value (`$set`)
+
+Use `$set` to entirely replace a value. This is the simplest operation which would replace the entire value.
+
+```js
+// Before: someKey = 'Old Value'
+customizationService.setCustomizations({
+ someKey: { $set: 'New Value' },
+});
+// After: someKey = 'New Value'
+```
+
+Example with study browser:
+
+```js
+// Before: studyBrowser.sortFunctions = []
+
+customizationService.setCustomizations({
+ 'studyBrowser.sortFunctions': {
+ $set: [
+ {
+ label: 'Sort by Patient ID',
+ sortFunction: (a, b) => a.PatientID.localeCompare(b.PatientID),
+ },
+ ],
+ },
+});
+
+// After: studyBrowser.sortFunctions = [{label: 'Sort by Patient ID', sortFunction: ...}]
+```
+
+---
+
+### 2. Add to an Array (`$push` and `$unshift`)
+
+- **`$push`**: Appends items to the end of an array.
+- **`$unshift`**: Adds items to the beginning of an array.
+
+```js
+// Before: NumbersList = [1, 2, 3]
+
+// Push items to the end
+customizationService.setCustomizations({
+ 'NumbersList': { $push: [5, 6] },
+});
+// After: NumbersList = [1, 2, 3, 5, 6]
+
+// Unshift items to the front
+customizationService.setCustomizations({
+ 'NumbersList': { $unshift: [0] },
+});
+// After: NumbersList = [0, 1, 2, 3, 5, 6]
+```
+
+---
+
+### 3. Insert at Specific Index (`$splice`)
+
+Use `$splice` to insert, replace, or remove items at a specific index in an array.
+
+```js
+// Before: NumbersList = [1, 2, 3]
+
+customizationService.setCustomizations({
+ 'NumbersList': {
+ $splice: [
+ [2, 0, 99], // Insert 99 at index 2
+ ],
+ },
+});
+// After: NumbersList = [1, 2, 99, 3]
+```
+
+---
+
+### 4. Merge Object Properties (`$merge`)
+
+Use `$merge` to update specific fields in an object without affecting other fields.
+
+```js
+// Before: SeriesInfo = { label: 'Original Label', sortFunction: oldFunc }
+
+customizationService.setCustomizations({
+ 'SeriesInfo': {
+ $merge: {
+ label: 'Updated Label',
+ extraField: true,
+ },
+ },
+});
+// After: SeriesInfo = { label: 'Updated Label', sortFunction: oldFunc, extraField: true }
+```
+
+Example with nested merge:
+```js
+// Before: SeriesInfo = { advanced: { subKey: 'oldValue' } }
+
+customizationService.setCustomizations({
+ 'SeriesInfo': {
+ advanced: {
+ $merge: {
+ subKey: 'updatedSubValue',
+ },
+ },
+ },
+});
+// After: SeriesInfo = { advanced: { subKey: 'updatedSubValue' } }
+```
+
+---
+
+### 5. Apply a Function (`$apply`)
+
+Use `$apply` when you need to compute the new value dynamically.
+
+```js
+// Before: SeriesInfo = { label: 'Old Label', data: 123 }
+
+customizationService.setCustomizations({
+ 'SeriesInfo': {
+ $apply: oldValue => ({
+ ...oldValue,
+ label: 'Computed Label',
+ }),
+ },
+});
+// After: SeriesInfo = { label: 'Computed Label', data: 123 }
+```
+
+---
+
+### 6. Filter and Modify (`$filter`)
+
+Use `$filter` to find specific items in arrays (or objects) and apply changes.
+
+```js
+// Before: advanced = {
+// functions: [
+// { id: 'seriesDate', label: 'Original Label' },
+// { id: 'other', label: 'Other Label' }
+// ]
+// }
+
+customizationService.setCustomizations({
+ 'advanced': {
+ $filter: {
+ match: { id: 'seriesDate' },
+ $merge: {
+ label: 'Updated via Filter',
+ },
+ },
+ },
+});
+// After: advanced = {
+// functions: [
+// { id: 'seriesDate', label: 'Updated via Filter' },
+// { id: 'other', label: 'Other Label' }
+// ]
+// }
+```
+
+:::note
+
+Note `$filter` will look recursively for
+an object that matches the `match` criteria and then apply the `$merge` or `$set` operation to it.
+
+Note in the example above we are not doing anything with the `functions` array.
+
+:::
+
+
+Example with deeply nested filter:
+```js
+// Before: advanced = {
+// functions: [{
+// id: 'seriesDate',
+// viewFunctions: [
+// { id: 'axial', label: 'Original Axial' }
+// ]
+// }]
+// }
+
+customizationService.setCustomizations({
+ 'advanced': {
+ $filter: {
+ match: { id: 'axial' },
+ $merge: {
+ label: 'Axial (via Filter)',
+ },
+ },
+ },
+});
+// After: advanced = {
+// functions: [{
+// id: 'seriesDate',
+// viewFunctions: [
+// { id: 'axial', label: 'Axial (via Filter)' }
+// ]
+// }]
+// }
+```
+
+---
+
+### Summary of Commands
+
+| **Command** | **Purpose** | **Example** |
+|-------------|-----------------------------------------------|---------------------------------------|
+| `$set` | Replace a value entirely | Replace a list or object |
+| `$push` | Append items to an array | Add to the end of a list |
+| `$unshift` | Prepend items to an array | Add to the start of a list |
+| `$splice` | Insert, remove, or replace at specific index | Modify specific indices in a list |
+| `$merge` | Update specific fields in an object | Change a subset of fields |
+| `$apply` | Compute the new value dynamically | Apply a function to transform values |
+| `$filter` | Find and update specific items in arrays | Target nested structures |
+| `$transform`| Apply a function to transform the customization | Apply a function to transform values |
+
+## Building Customizations Across Multiple Extensions
+
+Sometimes it is useful to build customizations across multiple extensions. For example, you may want to build a default list of tools inside a vieweport. But then each extension may want to add their own tools to the list.
+
+Lets say i have one default sorting function in my default extension.
+
+```js
+function getCustomizationModule() {
+ return [
+ {
+ name: 'default',
+ value: {
+ 'studyBrowser.sortFunctions': [
+ {
+ label: 'Series Number',
+ sortFunction: (a, b) => {
+ return a?.SeriesNumber - b?.SeriesNumber;
+ },
+ },
+ ],
+ },
+ },
+ ];
+}
+```
+
+This will result in having only series number as the default sorting function.
+
+but now in another extension let's say dicom-seg extension we can add another sorting function.
+
+```js
+function getCustomizationModule() {
+ return [
+ {
+ name: "dicom-seg-sorts",
+ value: {
+ "studyBrowser.sortFunctions": {
+ $push: [
+ {
+ label: "Series Date",
+ sortFunction: (a, b) => {
+ return a?.SeriesDate - b?.SeriesDate;
+ },
+ },
+ ],
+ },
+ },
+ },
+ ];
+}
+```
+
+But since the module is not `default` it will not get applied, but in my segmentation mode i can do
+
+
+```js
+onModeEnter() {
+ customizationService.setCustomizations([
+ '@ohif/extension-cornerstone-dicom-seg.customizationModule.dicom-seg-sorts',
+ ]);
+}
+```
+
+needless to say if you opted to choose `name: default` in the `getCustomizationModule` it was applied globally.
+
+## Customizable Parts of OHIF
+
+Below we are providing the example configuration for global scenario (using the configuration file), however, you can also use the `setCustomizations` method to set the customizations.
+
+{TableGenerator(customizations)}
diff --git a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx
new file mode 100644
index 000000000..f365cfc14
--- /dev/null
+++ b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx
@@ -0,0 +1,1180 @@
+import React from 'react';
+import measurementLabelsImage from '../../../assets/img/measurement-labels-auto.png';
+import seriesSortImage from '../../../assets/img/seriesSort.png';
+import windowLevelPresetsImage from '../../../assets/img/windowLevelPresets.png';
+import colorbarImage from '../../../assets/img/colorbarImage.png';
+import segmentationTableModeImage from '../../../assets/img/segmentationTableModeImage.png';
+import segmentationTableModeImage2 from '../../../assets/img/segmentationTableModeImage2.png';
+import segmentationShowAddSegmentImage from '../../../assets/img/segmentationShowAddSegmentImage.png';
+import layoutSelectorCommonPresetsImage from '../../../assets/img/layoutSelectorCommonPresetsImage.png';
+import layoutSelectorAdvancedPresetGeneratorImage from '../../../assets/img/layoutSelectorAdvancedPresetGeneratorImage.png';
+
+import segDisplayEditingTrue from '../../../assets/img/segDisplayEditingTrue.png';
+import segDisplayEditingFalse from '../../../assets/img/segDisplayEditingFalse.png';
+
+import thumbnailMenuItemsImage from '../../../assets/img/thumbnailMenuItemsImage.png';
+import studyMenuItemsImage from '../../../assets/img/studyMenuItemsImage.png';
+
+export const viewportOverlayCustomizations = [
+ {
+ id: 'viewportOverlay.topRight',
+ description: 'Defines the items displayed in the top-right overlay of the viewport.',
+ default: [],
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'viewportOverlay.topRight': {
+ $set: [
+ // Add your overlay items here, e.g.:
+ // { id: 'CustomOverlay', inheritsFrom: 'ohif.overlayItem.custom' },
+ ],
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'viewportOverlay.topLeft',
+ description: 'Defines the items displayed in the top-left overlay of the viewport.',
+ default: [
+ {
+ 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 }) =>
+ referenceInstance && referenceInstance.SeriesDescription,
+ contentF: ({ referenceInstance }) => referenceInstance.SeriesDescription,
+ },
+ ],
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'viewportOverlay.topLeft': {
+ $splice: [
+ [0, 1], // Remove 1 item starting at index 0 (removes StudyDate)
+ ],
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'viewportOverlay.bottomLeft',
+ description: 'Defines the items displayed in the bottom-left overlay of the viewport.',
+ default: [
+ {
+ id: 'WindowLevel',
+ inheritsFrom: 'ohif.overlayItem.windowLevel',
+ },
+ {
+ id: 'ZoomLevel',
+ inheritsFrom: 'ohif.overlayItem.zoomLevel',
+ condition: props => {
+ const activeToolName = props.toolGroupService.getActiveToolForViewport(props.viewportId);
+ return activeToolName === 'Zoom';
+ },
+ },
+ ],
+ configuration: `
+
+ // the following will push a yellow PatientNameOverlay to the bottomLeft overlay
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'viewportOverlay.bottomLeft': {
+ $push: [
+ {
+ id: 'PatientNameOverlay',
+ inheritsFrom: 'ohif.overlayItem',
+ attribute: 'PatientName',
+ label: 'PN:',
+ title: 'Patient Name',
+ color: 'yellow',
+ condition: ({ instance }) =>
+ instance &&
+ instance.PatientName &&
+ instance.PatientName.Alphabetic,
+ contentF: ({ instance, formatters: { formatPN } }) =>
+ formatPN(instance.PatientName.Alphabetic) +
+ ' ' +
+ (instance.PatientSex ? '(' + instance.PatientSex + ')' : ''),
+ },
+ ],
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'viewportOverlay.bottomRight',
+ description: 'Defines the items displayed in the bottom-right overlay of the viewport.',
+ default: [
+ {
+ id: 'InstanceNumber',
+ inheritsFrom: 'ohif.overlayItem.instanceNumber',
+ },
+ ],
+ configuration: `
+ // same as above
+ `,
+ },
+];
+
+export const customizations = [
+ {
+ id: 'measurementLabels',
+ description: 'Labels for measurement tools in the viewer that are automatically asked for.',
+ image: measurementLabelsImage,
+ default: [],
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ measurementLabels: {
+ $set: {
+ labelOnMeasure: true,
+ exclusive: true,
+ items: [
+ { value: 'Head', label: 'Head' },
+ { value: 'Shoulder', label: 'Shoulder' },
+ { value: 'Knee', label: 'Knee' },
+ { value: 'Toe', label: 'Toe' },
+ ],
+ },
+ },
+ },
+ ],
+};
+ `,
+ },
+
+ {
+ id: 'cornerstoneViewportClickCommands',
+ description: 'Defines the viewport event handlers such as button1, button2, doubleClick, etc.',
+ default: {
+ doubleClick: {
+ commandName: 'toggleOneUp',
+ commandOptions: {},
+ },
+ button1: {
+ commands: [
+ {
+ commandName: 'closeContextMenu',
+ },
+ ],
+ },
+ button3: {
+ commands: [
+ {
+ commandName: 'showCornerstoneContextMenu',
+ commandOptions: {
+ requireNearbyToolData: true,
+ menuId: 'measurementsContextMenu',
+ },
+ },
+ ],
+ },
+ },
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ cornerstoneViewportClickCommands: {
+ doubleClick: {
+ $push: [
+ () => {
+ console.debug('double click');
+ },
+ ],
+ },
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'cinePlayer',
+ description: 'Customizes the cine player component.',
+ default: 'The CinePlayer component in the UI',
+ configuration: null,
+ },
+ {
+ id: 'cornerstone.windowLevelPresets',
+ description: 'Window level presets for the cornerstone viewport.',
+ image: windowLevelPresetsImage,
+ default: {
+ CT: [
+ { description: 'Soft tissue', window: '400', level: '40' },
+ { description: 'Lung', window: '1500', level: '-600' },
+ { description: 'Liver', window: '150', level: '90' },
+ { description: 'Bone', window: '2500', level: '480' },
+ { description: 'Brain', window: '80', level: '40' },
+ ],
+
+ PT: [
+ { description: 'Default', window: '5', level: '2.5' },
+ { description: 'SUV', window: '0', level: '3' },
+ { description: 'SUV', window: '0', level: '5' },
+ { description: 'SUV', window: '0', level: '7' },
+ { description: 'SUV', window: '0', level: '8' },
+ { description: 'SUV', window: '0', level: '10' },
+ { description: 'SUV', window: '0', level: '15' },
+ ],
+ },
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'cornerstone.windowLevelPresets': {
+ $filter: {
+ match: { id: 'ct-soft-tissue' },
+ $merge: {
+ window: '500',
+ level: '50',
+ },
+ },
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'cornerstone.colorbar',
+ description: 'Customizes the appearance and behavior of the cornerstone colorbar.',
+ image: colorbarImage,
+ default: `
+ {
+ width: '16px',
+ colorbarTickPosition: 'left',
+ colormaps,
+ colorbarContainerPosition: 'right',
+ colorbarInitialColormap: DefaultColormap,
+ }
+ `,
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'cornerstone.colorbar': {
+ $merge: {
+ width: '20px',
+ colorbarContainerPosition: 'left',
+ },
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'cornerstone.3dVolumeRendering',
+ description:
+ 'Customizes the settings for 3D volume rendering in the cornerstone viewport, including presets and rendering quality range.',
+ default: `{
+ volumeRenderingPresets: VIEWPORT_PRESETS,
+ volumeRenderingQualityRange: {
+ min: 1,
+ max: 4,
+ step: 1,
+ },
+ }`,
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'cornerstone.3dVolumeRendering': {
+ $merge: {
+ volumeRenderingQualityRange: {
+ min: 2,
+ max: 6,
+ step: 0.5,
+ },
+ },
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'autoCineModalities',
+ description: 'Specifies the modalities for which the cine player automatically starts.',
+ default: ['OT', 'US'],
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'autoCineModalities': {
+ $set: ['OT', 'US', 'MR'], // Adds 'MR' as an additional modality for auto cine playback
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'cornerstone.overlayViewportTools',
+ description: 'Configures the tools available in the cornerstone SEG and RT tool groups.',
+ default: `{
+ 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,
+ },
+ },
+ ],
+ }`,
+ configuration: `
+ `,
+ },
+ {
+ id: 'layoutSelector.commonPresets',
+ description: 'Defines the default layout presets available in the application.',
+ image: layoutSelectorCommonPresetsImage,
+ default: [
+ {
+ 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,
+ },
+ },
+ ],
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'layoutSelector.commonPresets': {
+ $set: [
+ {
+ icon: 'layout-common-1x1',
+ commandOptions: {
+ numRows: 1,
+ numCols: 1,
+ },
+ },
+ {
+ icon: 'layout-common-1x2',
+ commandOptions: {
+ numRows: 1,
+ numCols: 2,
+ },
+ },
+ ],
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'layoutSelector.advancedPresetGenerator',
+ description: 'Generates advanced layout presets based on hanging protocols.',
+ image: layoutSelectorAdvancedPresetGeneratorImage,
+ default: `({ servicesManager }) => {
+ // by default any hanging protocol that has isPreset set to true will be included
+
+ // a function that returns an array of presets
+ // of form {
+ // icon: 'layout-common-1x1',
+ // title: 'Custom Protocol',
+ // commandOptions: {
+ // protocolId: 'customProtocolId',
+ // },
+ // disabled: false,
+ // }
+ }`,
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'layoutSelector.advancedPresetGenerator': {
+ $apply: (defaultGenerator) => {
+ return ({ servicesManager }) => {
+ const presets = defaultGenerator({ servicesManager });
+
+ // Add a custom preset for a specific hanging protocol
+ presets.push({
+ icon: 'custom-icon',
+ title: 'Custom Protocol',
+ commandOptions: {
+ protocolId: 'customProtocolId',
+ },
+ disabled: false,
+ });
+
+ return presets;
+ };
+ },
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'dicomUploadComponent',
+ description: 'Customizes the appearance and behavior of the dicom upload component.',
+ default: 'The DicomUpload component in the UI',
+ configuration: null,
+ },
+ {
+ id: 'onBeforeSRAddMeasurement',
+ description: 'Customizes the behavior of the SR measurement before it is added to the viewer.',
+ default: ({ measurement, StudyInstanceUID, SeriesInstanceUID }) => {
+ return measurement;
+ },
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ onBeforeSRAddMeasurement: {
+ $set: ({ measurement, StudyInstanceUID, SeriesInstanceUID }) => {
+ // Note: it should return measurement
+ console.debug('onBeforeSRAddMeasurement');
+ return measurement;
+ },
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'onBeforeDicomStore',
+ description:
+ 'A hook that modifies the DICOM dictionary before it is stored. The customization should return the modified DICOM dictionary.',
+ default: ({ dicomDict, measurementData, naturalizedReport }) => {
+ // Default implementation returns the DICOM dictionary as is
+ return dicomDict;
+ },
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'onBeforeDicomStore': {
+ $set: ({ dicomDict, measurementData, naturalizedReport }) => {
+ // Example customization: Add a custom tag to the DICOM dictionary
+ dicomDict['0010,0010'] = 'CustomizedPatientName'; // Patient's Name (example)
+ dicomDict['0008,103E'] = 'CustomStudyDescription'; // Study Description (example)
+
+ // Return the modified DICOM dictionary
+ return dicomDict;
+ },
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'sortingCriteria',
+ description:
+ 'Defines the series sorting criteria for hanging protocols. Note that this does not affect the order in which series are displayed in the study browser.',
+ default: `function seriesInfoSortingCriteria(firstSeries, secondSeries) {
+ const aLowPriority = isLowPriorityModality(firstSeries.Modality ?? firstSeries.modality);
+ const bLowPriority = isLowPriorityModality(secondSeries.Modality ?? secondSeries.modality);
+
+ if (aLowPriority) {
+ // Use the reverse sort order for low priority modalities so that the
+ // most recent one comes up first as usually that is the one of interest.
+ return bLowPriority ? defaultSeriesSort(secondSeries, firstSeries) : 1;
+ } else if (bLowPriority) {
+ return -1;
+ }
+
+ return defaultSeriesSort(firstSeries, secondSeries);
+ }`,
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'sortingCriteria': {
+ $set: function customSortingCriteria(firstSeries, secondSeries) {
+
+ return someSort(firstSeries, secondSeries);
+ },
+ },
+ },
+ ],
+};
+ `,
+ },
+];
+
+export const segmentationCustomizations = [
+ {
+ id: 'panelSegmentation.tableMode',
+ description: 'Defines the mode of the segmentation table.',
+ image: [segmentationTableModeImage, segmentationTableModeImage2],
+ default: 'collapsed',
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'panelSegmentation.tableMode': {
+ $set: 'expanded',
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'panelSegmentation.showAddSegment',
+ description:
+ 'Controls whether the "Add Segment" button is displayed in the segmentation panel.',
+ default: true,
+ image: segmentationShowAddSegmentImage,
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'panelSegmentation.showAddSegment': {
+ $set: false, // Set to false to hide the "Add Segment" button
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'panelSegmentation.readableText',
+ description: 'Defines the readable text labels for segmentation panel statistics and metrics.',
+ default: {
+ 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',
+ },
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'panelSegmentation.readableText': {
+ $merge: {
+ lesionStats: 'Lesion Stats',
+ },
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'panelSegmentation.onSegmentationAdd',
+ description: 'Defines the behavior when a new segmentation is added to the segmentation panel.',
+ default: `() => {
+ // default is to create a labelmap for the active viewport
+ const { viewportGridService } = servicesManager.services;
+ const viewportId = viewportGridService.getState().activeViewportId;
+ commandsManager.run('createLabelmapForViewport', { viewportId });
+ }`,
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'panelSegmentation.onSegmentationAdd': {
+ $set: () => {
+ const { viewportGridService } = servicesManager.services;
+ const viewportId = viewportGridService.getState().activeViewportId;
+ commandsManager.run('createNewLabelmapFromPT');
+ },
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'panelSegmentation.disableEditing',
+ description: 'Determines whether editing of segmentations in the panel is disabled.',
+ default: false,
+ image: [segDisplayEditingTrue, segDisplayEditingFalse],
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'panelSegmentation.disableEditing': {
+ $set: true, // Disables editing of segmentations in the panel
+ },
+ },
+ ],
+};
+ `,
+ },
+];
+
+export const measurementsCustomizations = [
+ {
+ id: 'panelMeasurement.disableEditing',
+ description:
+ 'Determines whether editing measurements in the viewport is disabled after SR hydration',
+ default: false,
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'panelMeasurement.disableEditing': {
+ $set: true, // Disables editing measurements in the panel
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'cornerstone.measurements',
+ description:
+ 'Defines configuration for measurement tools, including display text and reporting options.',
+ default: {
+ 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',
+ },
+ ],
+ 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',
+ },
+ ],
+ },
+ },
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'cornerstone.measurements': {
+ $set: {
+ SplineROI: {
+ displayText: [
+ {
+ displayName: 'Area',
+ 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',
+ },
+ ],
+ },
+ },
+ },
+ },
+ ],
+};
+ `,
+ },
+];
+
+export const studyBrowserCustomizations = [
+ {
+ id: 'studyBrowser.studyMode',
+ description:
+ 'Controls the study browser mode to determine whether to show all studies (including prior studies) or only the current study.',
+ default: `'all'`,
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'studyBrowser.studyMode': {
+ $set: 'primary', // or recent
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'studyBrowser.viewPresets',
+ description: 'Defines the view presets for the study browser, such as list or thumbnail views.',
+ default: [
+ {
+ id: 'list',
+ iconName: 'ListView',
+ selected: false,
+ },
+ {
+ id: 'thumbnails',
+ iconName: 'ThumbnailView',
+ selected: true,
+ },
+ ],
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'studyBrowser.viewPresets': {
+ $set: [
+ {
+ id: 'list',
+ iconName: 'ListView',
+ selected: true, // Makes the list view the default selected option
+ },
+ {
+ id: 'thumbnails',
+ iconName: 'ThumbnailView',
+ selected: false,
+ },
+ ],
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'studyBrowser.sortFunctions',
+ description: 'Sorting options for study browser items.',
+ image: seriesSortImage,
+ default: [
+ {
+ 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();
+ },
+ },
+ ],
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'studyBrowser.sortFunctions': {
+ $push: [
+ {
+ label: 'Series Stuff',
+ sortFunction: (a, b) => Stuff,
+ },
+ ],
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'studyBrowser.thumbnailMenuItems',
+ description:
+ 'Defines the menu items available in the thumbnail menu items of the study browser.',
+ image: thumbnailMenuItemsImage,
+ default: [
+ {
+ id: 'tagBrowser',
+ label: 'Tag Browser',
+ iconName: 'DicomTagBrowser',
+ commands: 'openDICOMTagViewer',
+ },
+ ],
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'studyBrowser.thumbnailMenuItems': {
+ $set: [
+ {
+ id: 'tagBrowser',
+ label: 'Tag Browser',
+ iconName: 'DicomTagBrowser',
+ commands: 'openDICOMTagViewer',
+ },
+ {
+ id: 'deleteThumbnail',
+ label: 'Delete',
+ iconName: 'Delete',
+ commands: 'deleteThumbnail',
+ },
+ {
+ id: 'markAsFavorite',
+ label: 'Mark as Favorite',
+ commands: 'markAsFavorite',
+ },
+ ],
+ },
+ },
+ ],
+};
+ `,
+ },
+ {
+ id: 'studyBrowser.studyMenuItems',
+ description: 'Defines the menu items available in the study menu items of the study browser.',
+ image: studyMenuItemsImage,
+ default: [],
+ configuration: `
+window.config = {
+ // rest of window config
+ customizationService: [
+ {
+ 'studyBrowser.studyMenuItems': {
+ $set: [
+ {
+ id: 'downloadStudy',
+ label: 'Download Study',
+ iconName: 'Download',
+ commands: () => {
+ console.debug('downloadStudy');
+ },
+ },
+ ],
+ },
+ },
+ ],
+};
+ `,
+ },
+];
+
+export const TableGenerator = (customizations: any[]) => {
+ return customizations.map(({ id, description, default: defaultValue, configuration, image }) => (
+
diff --git a/platform/docs/docs/platform/services/ui/customization-service.md b/platform/docs/docs/platform/services/ui/customization-service.md
deleted file mode 100644
index a6baf90f4..000000000
--- a/platform/docs/docs/platform/services/ui/customization-service.md
+++ /dev/null
@@ -1,504 +0,0 @@
----
-sidebar_position: 7
-sidebar_label: Customization Service
----
-# Customization Service
-
-There are a lot of places where users may want to configure certain elements
-differently between different modes or for different deployments. A mode
-example might be the use of a custom overlay showing mode related DICOM header
-information such as radiation dose or patient age.
-
-The use of this service enables these to be defined in a typed fashion by
-providing an easy way to set default values for this, but to allow a
-non-default value to be specified by the configuration or mode.
-
-This service is a UI service in that part of the registration allows for registering
-UI components and types to deal with, but it does not directly provide an UI
-displayable elements unless customized to do so.
-
-Note: Customization Service itself doesn't implement the actual customization,
-but rather just provide mechanism to register reusable prototypes, to configure
-those prototypes with actual configurations, and to use the configured objects
-(components, data, whatever).
-Actual implementation of the customization is totally up to the component that
-supports customization. (for example, `CustomizableViewportOverlay` component uses
-`CustomizationService` to implement viewport overlay that is easily customizable
-from configuration.)
-
-## Global, Default and Mode customizations
-There are various customization sets that define the lifetime/setup of the
-customization. The global customizations are those used for overriding
-customizations defined elsewhere, and allow replacing a customization.
-
-Mode customizations are only registered for the lifetime of the mode, allowing
-the mode definition to update/modify the underlying behaviour. This is related
-to default customizations, which provide a fallback if the mode or global customization
-isn't defined. Default customizations may only be defined once, otherwise throwing
-an exception.
-
-## Append and Merge Customizations
-In addition to the replace a customization, there is the ability to merge or append
-a customization. The merge customization simply applies the lodash merge functionality
-to the existing customization, with the new one, while the append customization
-modifies the customization by appending to the value.
-
-### Append Behaviour
-When a list is found in the destination object, the append source object is
-examined to see how to handle the change. If the source is simply a list,
-then the list object is appended, and no additional changes are performed.
-However, if the source is an object other than a list, then the iterable
-attributes of the object are examined to match child objects to the destination list,
-according to the following table:
-
-* Natural or zero number value - match the given index location and merge at the point
-* Fractional number value - insert at a new point in the list, starting from the end or beginning
-* keyword - match a value having the same id as the keyword, inserting at the end, or at _priority as defined in the keywords above.
-
-#### Example Append
-
-```javascript
-const destination = [
- 1,
- {id: 'two', value: 2},
- {id: 'three', value: 3}
-]
-
-const source = {
- two: { value: 'updated2' },
- 1: { extraValue: 2 },
- 1.0001: { id: 'inserted', value: 1.0001 },
- -1: { value: -3 },
-}
-```
-
-Results in two updates to `destination[1]`, the first using an id match on 'two', while the second one
-does a positional match on `1`, resulting in the value `{id: 'two', value: 'updated2', extraValue: 2 }`
-
-Then, it inserts the id 'inserted' after position 1.
-
-Finally, position -1 (the end position) is updated from value 3 to value -3.
-
-The ordering is not specified on any of these insertions, so can happen out of order. Use multiple updates to perform order specific inserts.
-
-## Registering customizable modules (or defining customization prototypes)
-
-Extensions and Modes can register customization templates they support.
-It is done by adding `getCustomizationModule()` in the extension or mode definition.
-
-Below is the protocol of the `getCustomizationModule()`, if defined in Typescript.
-
-```typescript
- getCustomizationModule() : { name: string, value: any }[]
-```
-
-If the name is 'default', it is the a default customization, while if it
-is 'global', then it is a priority/over-riding customization.
-
-In the `value` of each customizations, you will define customization prototype(s).
-These customization prototype(s) can be considered like "Prototype" in Javascript.
-These can be used to extend the customization definitions from configurations.
-Default customizations will be often used to define all the customization prototypes,
-Default customizations will be often used to define all the customization prototypes,
-as they will be loaded automatically along with the defining extension or mode.
-
-
-For example, the `@ohif/extension-default` extension defines,
-
-```js
- getCustomizationModule: () => [
- //...
-
- {
- name: 'default',
- value: [
- {
- 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 (
-
- {this.label && (
- {this.label}
- )}
- {value}
-
- );
- },
- },
- ],
- },
-
- //...
- ],
-```
-
-And this `ohif.overlayItem` object will be used as a prototype (and template) to define items
-to be displayed on `CustomizableViewportOverlay`. See how we use the `ohif.overlayItem` in
-the example below.
-
-## Configuring customizations
-
-There are several ways to register customizations. The
-`APP_CONFIG.customizationService`
-field is used as a per-configuration entry. This object can list single
-configurations by id, or it can list sets of customizations by referring to
-the `customizationModule` in an extension.
-
-NOTE that these definitions from APP_CONFIG will be loaded by default, just like
-extension/modes default customization.
-
-Below is the example configuration for `CustomizableViewportOverlay` component
-customization, using the customization prototype `ohif.overlayItem` defined in
-`ohif/extension-defaul` extension.:
-
-```js
-window.config = {
- //...
-
- // in the APP_CONFIG file set the top right area to show the patient name
- // using PN: as a prefix when the study has a non-empty patient name.
- customizationService: {
- cornerstoneOverlayTopRight: {
- 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 &&
- instance.PatientName &&
- instance.PatientName.Alphabetic,
- contentF: ({ instance, formatters: { formatPN } }) =>
- formatPN(instance.PatientName.Alphabetic) +
- ' ' +
- (instance.PatientSex ? '(' + instance.PatientSex + ')' : ''),
- },
- ],
- },
- },
-
- //...
-}
-```
-
-In the customization configuration, you can use `customizationType` fields to
-define the prototype that customization object should inherit from.
-The `customizationType` field is simply the id of another customization object.
-
-
-## Implementing customization using CustomizationService
-
-### Mode Customizations
-
-Mode-specific customizations are no different from the global ones,
-except that the mode customizations are specific to one mode and
-are not globally applied. Mode-specific customizations are also cleared
-before the mode `onModeEnter` is called, and they can have new values registered in the `onModeEnter`
-
-Following on our example above to customize the overlay, we can now add a mode customization
-with a bottom-right overlay.
-
-```js
-// Import the type from the extension itself
-import OverlayUICustomization from "@ohif/cornerstone-extension";
-
-// In the mode itself, customizations can be registered:
-onModeEnter: {
- // Note how the object can be strongly typed
- const bottomRight: OverlayUICustomization = {
- id: 'cornerstoneOverlayBottomRight',
- // Note the type is the previously registered ohif.cornerstoneOverlay
- customizationType: 'ohif.cornerstoneOverlay',
- // The cornerstoneOverlay definition requires an items list here.
- items: [
- // Custom definitions for the context menu here.
- ],
- };
- customizationService.addModeCustomizations(bottomRight);
-}
-```
-
-The mode customizations are retrieved via the `getModeCustomization` function,
-providing an id, and optionally a default value. The retrieval will return,
-in order:
-
-1. Global customization with the given id.
-2. Mode customization with the id.
-3. The default value specified.
-
-The return value then inherits the `customizationType` instance, so that the
-value can be typed and have default values and functionality provided. The object
-can then be used in a way defined by the extension provided that customization
-point.
-
-```ts
-const cornerstoneOverlay = customizationService.getModeCustomization(
- "cornerstoneOverlay",
- { customizationType: "ohif.cornerstoneOverlay" },
-);
-
-const { component: overlayComponent, props } =
- customizationService.getComponent(cornerstoneOverlay);
-
-return (
-
-);
-```
-
-This example shows fetching the default component to render this object. The
-returned object would be a sub-type of ohif.cornerstoneOverlay if defined. This
-object can be a React component or other object such as a commands list, for
-example (this example comes from the context menu customizations as that one
-uses commands lists):
-
-```ts
-cornerstoneContextMenu = customizationService.get(
- "cornerstoneContextMenu",
- defaultMenu,
-);
-commandsManager.run(cornerstoneContextMenu, extraProps);
-```
-
-### Global Customizations
-
-Global customizations are retrieved in the same was as mode customizations, except
-that the `getGlobalCustomization` is called instead of the mode call.
-
-### Types
-
-Some types for the customization service are provided by the `@ohif/ui` types
-export. Additionally, extensions can provide a Types export with custom
-typing, allowing for better typing for the extension specific capabilities.
-This allows for having strong typing when declaring customizations, for example:
-
-```ts
-import { Types } from '@ohif/ui';
-
-const customContextMenu: Types.ContextMenu.Menu =
- {
- id: 'cornerstoneContextMenu',
- customizationType: 'ohif.contextMenu',
- // items will be type checked to be in accordance with UIContextMenu.items
- items: [ ... ]
- },
-```
-
-### Inheritance
-
-JavaScript property inheritance can be supplied by defining customizations
-with id corresponding to the customizationType value. For example:
-
-```js
-getCustomizationModule = () => ([
- {
- name: 'default',
- value: [
- {
- id: 'ohif.overlayItem',
- content: function (props) {
- return (
{this.label} {props.instance[this.attribute]}
)
- },
- },
- ],
- }
-])
-```
-
-defines an overlay item which has a React content object as the render value.
-This can then be used by specifying a `customizationType` of `ohif.overlayItem`, for example:
-
-```js
-const overlayItem: Types.UIOverlayItem = {
- id: 'anOverlayItem',
- customizationType: 'ohif.overlayItem',
- attribute: 'PatientName',
- label: 'PN:',
-};
-```
-
-# Customizations
-
-This section can be used to specify various customization capabilities.
-
-## Text color for StudyBrowser tabs
-
-This is the recommended pattern for deep customization of class attributes,
-making it fine grained, and have it apply a set of attributes, mostly from
-tailwind. In this case it is a double indirection, as the buttons class
-uses it's own internal class names.
-
-* Name: 'class:StudyBrowser'
-* Attributes:
-** `true` for the is active true text color
-** `false` for the is active false text color.
-** Values are button colors, from the Button class, eg default, white, black
-
-## customRoutes
-
-* Name: `customRoutes` global
-* Attributes:
-** `routes` of type List of route objects (see `route/index.tsx`) is a set of route objects to add.
-** Should any element of routes match an existing baked in element, the baked in one will be replaced.
-** `notFoundRoute` is the route to display when nothing is found (this has to be at the end of the overall list, so can't be added to routes)
-
-### Example
-
-```js
-{
- id: 'customRoutes',
- routes: [
- {
- path: '/myroute',
- children: MyRouteReactFunction,
- }
- ],
-}
-```
-
-There is a usage of this example commented out in config/default.js that
-looks like the code below. This example is provided by the default extension,
-again with commented out code. Uncomment the getCustomizationModule customRoutes
-code in the default module to activate this, and then go to: `http://localhost:3000/custom`
-to see the custom route.
-
-Note the name of this is the customization module name, which usually won't match
-the id, and in fact there can be multiple customization objects defined for a single
-customization module, to allow for customizing sets of related values.
-
-```js
-customizationService: [
- // Shows a custom route -access via http://localhost:3000/custom
- '@ohif/extension-default.customizationModule.helloPage',
-],
-```
-
-## Customizable Viewport Overlay
-
-Below is the full example configuration of the customizable viewport overlay and the screenshot of the result overlay.
-
-There are working examples that can be run with:
-```
-set APP_CONFIG=config/customization.js
-yarn dev
-```
-
-```javascript
-// this is part of customization.js, an example customization dataset
-window.config = {
-
- // This shows how to append to the customization data
- customizationService: [
- {
- 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,
- },
- },
- },
- },
-...
-```
-
-
-
-## Context Menus
-
-Context menus can be created by defining the menu structure and click
-interaction, as defined in the `ContextMenu/types`. There are examples
-below specific to the cornerstone context, because the actual click
-handler and attributes used to decide when and how to display the menu
-are specific to the context used for where the menu is displayed.
-
-## Cornerstone Context Menu
-
-The default cornerstone context menu can be customized by setting the
-`cornerstoneContextMenu`. For a full example, see `findingsContextMenu`.
-
-## Customizable Cornerstone Viewport Click Behaviour
-
-The behaviour on clicking on the cornerstone viewport can be customized
-by setting the `cornerstoneViewportClickCommands`. This is intended to
-support both the cornerstone 3D internal commands as well as things like
-context menus. Currently it supports buttons 1-3, as well as modifier keys
-by associating a commands list with the button to click. See `initContextMenu`
-for more details.
-
-## Please add additional customizations above this section
-> 3rd Party implementers may be added to this table via pull requests.
-
-
-
-
-[interface]: https://github.com/OHIF/Viewers/blob/master/platform/core/src/services/UIModalService/index.js
-[modal-provider]: https://github.com/OHIF/Viewers/blob/master/platform/ui/src/contextProviders/ModalProvider.js
-[modal-consumer]: https://github.com/OHIF/Viewers/tree/master/platform/ui/src/components/ohifModal
-[ux-article]: https://uxplanet.org/best-practices-for-modals-overlays-dialog-windows-c00c66cddd8c
-
diff --git a/platform/docs/src/css/custom.css b/platform/docs/src/css/custom.css
index 1d2a96570..48c072bd7 100644
--- a/platform/docs/src/css/custom.css
+++ b/platform/docs/src/css/custom.css
@@ -578,6 +578,16 @@ ul li {
margin-bottom: 0.5rem;
}
+ol {
+ list-style-type: decimal;
+ padding-left: 1.5rem;
+ margin: 1rem 0;
+}
+
+ol li {
+ margin-bottom: 0.5rem;
+}
+
/* Nested bullet points */
ul ul {
list-style-type: circle;
diff --git a/platform/docs/versioned_docs/version-3.9/migration-guide/3p8-to-3p9/8-Refactorings.md b/platform/docs/versioned_docs/version-3.9/migration-guide/3p8-to-3p9/8-Refactorings.md
index 795b3e3c6..7554a184d 100644
--- a/platform/docs/versioned_docs/version-3.9/migration-guide/3p8-to-3p9/8-Refactorings.md
+++ b/platform/docs/versioned_docs/version-3.9/migration-guide/3p8-to-3p9/8-Refactorings.md
@@ -67,7 +67,7 @@ customizationService.addModeCustomizations([
customizationService.addModeCustomizations([
// To disable editing in the SegmentationTable
{
- id: 'PanelSegmentation.disableEditing',
+ id: 'panelSegmentation.disableEditing',
disableEditing: true,
},
// To disable editing in the MeasurementTable
@@ -106,11 +106,11 @@ customizationService.addModeCustomizations([
```js
customizationService.addModeCustomizations([
{
- id: 'PanelSegmentation.tableMode',
+ id: 'panelSegmentation.tableMode',
mode: 'expanded',
},
{
- id: 'PanelSegmentation.onSegmentationAdd',
+ id: 'panelSegmentation.onSegmentationAdd',
onSegmentationAdd: () => {
commandsManager.run('createNewLabelmapFromPT');
},
diff --git a/platform/ui-next/src/components/DataRow/DataRow.tsx b/platform/ui-next/src/components/DataRow/DataRow.tsx
index de584b398..9daf18689 100644
--- a/platform/ui-next/src/components/DataRow/DataRow.tsx
+++ b/platform/ui-next/src/components/DataRow/DataRow.tsx
@@ -261,25 +261,28 @@ const DataRow: React.FC = ({
{/* Lock Icon (if needed) */}
- {isLocked && }
+ {isLocked && !disableEditing && }
{/* Actions Dropdown Menu */}
- setIsDropdownOpen(open)}>
-
-
-
-
- {!disableEditing && (
+ {disableEditing && }
+ {!disableEditing && (
+ setIsDropdownOpen(open)}>
+
+
+
+
<>
handleAction('Rename', e)}>
@@ -295,14 +298,14 @@ const DataRow: React.FC = ({
Change Color
)}
+ handleAction('Lock', e)}>
+
+ {isLocked ? 'Unlock' : 'Lock'}
+
>
- )}
- handleAction('Lock', e)}>
-
- {isLocked ? 'Unlock' : 'Lock'}
-
-
-
+
+
+ )}
diff --git a/platform/ui-next/src/components/StudyBrowser/StudyBrowser.tsx b/platform/ui-next/src/components/StudyBrowser/StudyBrowser.tsx
index 41de81eca..244b89546 100644
--- a/platform/ui-next/src/components/StudyBrowser/StudyBrowser.tsx
+++ b/platform/ui-next/src/components/StudyBrowser/StudyBrowser.tsx
@@ -28,7 +28,7 @@ const StudyBrowser = ({
const viewPreset = viewPresets
? viewPresets.filter(preset => preset.selected)[0]?.id
: 'thumbnails';
- return tabData.studies.map(
+ return tabData?.studies?.map(
({ studyInstanceUid, date, description, numInstances, modalities, displaySets }) => {
const isExpanded = expandedStudyInstanceUIDs.includes(studyInstanceUid);
return (
diff --git a/platform/ui-next/src/components/StudyBrowserSort/StudyBrowserSort.tsx b/platform/ui-next/src/components/StudyBrowserSort/StudyBrowserSort.tsx
index 862ef9d6f..e9f871715 100644
--- a/platform/ui-next/src/components/StudyBrowserSort/StudyBrowserSort.tsx
+++ b/platform/ui-next/src/components/StudyBrowserSort/StudyBrowserSort.tsx
@@ -12,7 +12,7 @@ export function StudyBrowserSort({ servicesManager }: withAppTypes) {
// Todo: this should not be here, no servicesManager should be in ui-next, only
// customization service
const { customizationService, displaySetService } = servicesManager.services;
- const { values: sortFunctions } = customizationService.get('studyBrowser.sortFunctions');
+ const sortFunctions = customizationService.getCustomization('studyBrowser.sortFunctions');
const [selectedSort, setSelectedSort] = useState(sortFunctions[0]);
const [sortDirection, setSortDirection] = useState('ascending');
diff --git a/platform/ui/src/components/MeasurementTable/MeasurementTable.tsx b/platform/ui/src/components/MeasurementTable/MeasurementTable.tsx
index 3a89ea64b..0a5c32352 100644
--- a/platform/ui/src/components/MeasurementTable/MeasurementTable.tsx
+++ b/platform/ui/src/components/MeasurementTable/MeasurementTable.tsx
@@ -17,11 +17,7 @@ const MeasurementTable = ({
const { t } = useTranslation('MeasurementTable');
const amount = data.length;
- const itemCustomization = customizationService.getCustomization('MeasurementItem', {
- id: 'MeasurementItem',
- content: MeasurementItem,
- contentProps: {},
- }) as Types.Customization;
+ const itemCustomization = customizationService.getCustomization('MeasurementItem');
const CustomMeasurementItem = itemCustomization.content;
diff --git a/platform/ui/src/components/SelectTree/SelectTree.tsx b/platform/ui/src/components/SelectTree/SelectTree.tsx
index 0217de2c9..254c85c3a 100644
--- a/platform/ui/src/components/SelectTree/SelectTree.tsx
+++ b/platform/ui/src/components/SelectTree/SelectTree.tsx
@@ -186,7 +186,7 @@ export class SelectTree extends Component {