feat: Add extensibility for tmtv and segmentation modes (#6128)

* feat: Add extensibility for tmtv and segmentation modes

* Fixes for ordering issues on laod

* Remove unnecessary reference lookup

* Chane side panel timing to fix tests

* PR comments - change how mode definitions get created

* Improvements to mode customizations

* Start organizing customizations

* Misc fixes for a customization demo page

* Security fixes

* PR requested changes to naming
This commit is contained in:
Bill Wallace 2026-07-10 12:43:17 -04:00 committed by GitHub
parent f79055f98e
commit b266c0a86a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 2653 additions and 1215 deletions

View File

@ -244,6 +244,32 @@ also supports a number of commands that can be found in their respective
\* - For more information on different builds, check out our [Deploy
Docs][deployment-docs]
### Which config each command uses
The dev server and the production build select a different default
[configuration file][config-file] when `APP_CONFIG` is not set explicitly:
| Command | Default config | Data sources | `?customization=` |
| ------------------------------- | -------------------- | ------------ | ----------------- |
| `dev`, `dev:fast`, `start` | `config/dev.js` | Full set | Enabled |
| `build` | `config/default.js` | One demo source | Disabled |
- **`config/dev.js`** is the full-featured local-development config: every data
source is enabled, the `?customization=` URL feature is turned on (via
`customizationUrlPrefixes`), and it is kept at parity with the public demo
(`config/netlify.js`) so customizations behave locally the same way they do on
the demo.
- **`config/netlify.js`** is the public demo / Netlify deploy config
(`build:viewer:ci`), with the same full data-source set and `?customization=`
enabled.
- **`config/default.js`** is a locked-down baseline and is now **only** the
default for a plain production build (`build` with no `APP_CONFIG`): a single
read-only demo data source and `?customization=` off.
Any explicit `APP_CONFIG` overrides the default, e.g.
`APP_CONFIG=config/default.js pnpm run dev` or
`APP_CONFIG=config/netlify.js pnpm run build`.
## Project
The OHIF Medical Image Viewing Platform is maintained as a
@ -371,6 +397,7 @@ MIT © [OHIF](https://github.com/OHIF)
[ohif-architecture]: https://docs.ohif.org/architecture/index.html
[ohif-extensions]: https://docs.ohif.org/architecture/index.html
[deployment-docs]: https://docs.ohif.org/deployment/
[config-file]: https://docs.ohif.org/configuration/configurationFiles
[react-url]: https://reactjs.org/
[pwa-url]: https://developers.google.com/web/progressive-web-apps/
[ohif-viewer-url]: https://www.npmjs.com/package/@ohif/app

View File

@ -1,8 +1,11 @@
import type { Button } from '@ohif/core/types';
import { ViewportGridService } from '@ohif/core';
import { ViewportGridService, ToolbarService } from '@ohif/core';
import i18n from 'i18next';
import { MIN_SEGMENTATION_DRAWING_RADIUS, MAX_SEGMENTATION_DRAWING_RADIUS } from './constants';
const { TOOLBAR_SECTIONS } = ToolbarService;
export const MIN_SEGMENTATION_DRAWING_RADIUS = 0.5;
export const MAX_SEGMENTATION_DRAWING_RADIUS = 99.5;
const setToolActiveToolbar = {
commandName: 'setToolActiveToolbar',
@ -20,155 +23,16 @@ const callbacks = (toolName: string) => [
},
];
export const toolbarButtons: Button[] = [
{
id: 'AdvancedRenderingControls',
uiType: 'ohif.advancedRenderingControls',
props: {
buttonSection: true,
},
},
{
id: 'modalityLoadBadge',
uiType: 'ohif.modalityLoadBadge',
props: {
icon: 'Status',
label: i18n.t('Buttons:Status'),
tooltip: i18n.t('Buttons:Status'),
evaluate: {
name: 'evaluate.modalityLoadBadge',
hideWhenDisabled: true,
},
},
},
{
id: 'navigationComponent',
uiType: 'ohif.navigationComponent',
props: {
icon: 'Navigation',
label: i18n.t('Buttons:Navigation'),
tooltip: i18n.t('Buttons:Navigate between segments/measurements and manage their visibility'),
evaluate: {
name: 'evaluate.navigationComponent',
hideWhenDisabled: true,
},
},
},
{
id: 'trackingStatus',
uiType: 'ohif.trackingStatus',
props: {
icon: 'TrackingStatus',
label: i18n.t('Buttons:Tracking Status'),
tooltip: i18n.t('Buttons:View and manage tracking status of measurements and annotations'),
evaluate: {
name: 'evaluate.trackingStatus',
hideWhenDisabled: true,
},
},
},
{
id: 'dataOverlayMenu',
uiType: 'ohif.dataOverlayMenu',
props: {
icon: 'ViewportViews',
label: i18n.t('Buttons:Data Overlay'),
tooltip: i18n.t(
'Buttons:Configure data overlay options and manage foreground/background display sets'
),
evaluate: 'evaluate.dataOverlayMenu',
},
},
{
id: 'orientationMenu',
uiType: 'ohif.orientationMenu',
props: {
icon: 'OrientationSwitch',
label: i18n.t('Buttons:Orientation'),
tooltip: i18n.t(
'Buttons:Change viewport orientation between axial, sagittal, coronal and reformat planes'
),
evaluate: {
name: 'evaluate.orientationMenu',
// hideWhenDisabled: true,
},
},
},
{
id: 'windowLevelMenuEmbedded',
uiType: 'ohif.windowLevelMenuEmbedded',
props: {
icon: 'WindowLevel',
label: i18n.t('Buttons:Window Level'),
tooltip: i18n.t('Buttons:Adjust window/level presets and customize image contrast settings'),
evaluate: {
name: 'evaluate.windowLevelMenuEmbedded',
hideWhenDisabled: true,
},
},
},
{
id: 'windowLevelMenu',
uiType: 'ohif.windowLevelMenu',
props: {
icon: 'WindowLevel',
label: i18n.t('Buttons:Window Level'),
tooltip: i18n.t('Buttons:Adjust window/level presets and customize image contrast settings'),
evaluate: 'evaluate.windowLevelMenu',
},
},
{
id: 'voiManualControlMenu',
uiType: 'ohif.voiManualControlMenu',
props: {
icon: 'WindowLevelAdvanced',
label: i18n.t('Buttons:Advanced Window Level'),
tooltip: i18n.t('Buttons:Advanced window/level settings with manual controls and presets'),
evaluate: 'evaluate.voiManualControlMenu',
},
},
{
id: 'thresholdMenu',
uiType: 'ohif.thresholdMenu',
props: {
icon: 'Threshold',
label: i18n.t('Buttons:Threshold'),
tooltip: i18n.t('Buttons:Image threshold settings'),
evaluate: {
name: 'evaluate.thresholdMenu',
hideWhenDisabled: true,
},
},
},
{
id: 'opacityMenu',
uiType: 'ohif.opacityMenu',
props: {
icon: 'Opacity',
label: i18n.t('Buttons:Opacity'),
tooltip: i18n.t('Buttons:Image opacity settings'),
evaluate: {
name: 'evaluate.opacityMenu',
hideWhenDisabled: true,
},
},
},
{
id: 'Colorbar',
uiType: 'ohif.colorbar',
props: {
type: 'tool',
label: i18n.t('Buttons:Colorbar'),
},
},
// sections
{
id: 'MoreTools',
uiType: 'ohif.toolButtonList',
props: {
buttonSection: true,
},
},
/**
* Segmentation editing toolbar buttons: the toolbox section containers plus
* the labelmap / contour editing tools and utilities. These complement the
* general buttons in `cornerstone.toolbarButtons`; together they are the
* default button set for the segmentation mode, and modes such as basic /
* longitudinal can pull them in via a customization (see
* `segmentationEditing.jsonc`).
*/
const segmentationToolbarButtons: Button[] = [
// section containers for the nested toolboxes and toolbars
{
id: 'BrushTools',
uiType: 'ohif.toolBoxButtonGroup',
@ -176,7 +40,6 @@ export const toolbarButtons: Button[] = [
buttonSection: true,
},
},
// Section containers for the nested toolboxes and toolbars.
{
id: 'LabelMapUtilities',
uiType: 'ohif.Toolbar',
@ -206,215 +69,6 @@ export const toolbarButtons: Button[] = [
},
},
// tool defs
{
id: 'Zoom',
uiType: 'ohif.toolButton',
props: {
icon: 'tool-zoom',
label: i18n.t('Buttons:Zoom'),
commands: setToolActiveToolbar,
evaluate: 'evaluate.cornerstoneTool',
},
},
{
id: 'WindowLevel',
uiType: 'ohif.toolButton',
props: {
icon: 'tool-window-level',
label: i18n.t('Buttons:Window Level'),
commands: setToolActiveToolbar,
evaluate: 'evaluate.cornerstoneTool',
},
},
{
id: 'Pan',
uiType: 'ohif.toolButton',
props: {
icon: 'tool-move',
label: i18n.t('Buttons:Pan'),
commands: setToolActiveToolbar,
evaluate: 'evaluate.cornerstoneTool',
},
},
{
id: 'TrackballRotate',
uiType: 'ohif.toolButton',
props: {
type: 'tool',
icon: 'tool-3d-rotate',
label: i18n.t('Buttons:3D Rotate'),
commands: setToolActiveToolbar,
evaluate: {
name: 'evaluate.cornerstoneTool',
disabledText: i18n.t('Buttons:Select a 3D viewport to enable this tool'),
},
},
},
{
id: 'Capture',
uiType: 'ohif.toolButton',
props: {
icon: 'tool-capture',
label: i18n.t('Buttons:Capture'),
commands: 'showDownloadViewportModal',
evaluate: [
'evaluate.action',
{
name: 'evaluate.viewport.supported',
unsupportedViewportTypes: ['video', 'wholeSlide'],
},
],
},
},
{
id: 'Layout',
uiType: 'ohif.layoutSelector',
props: {
rows: 3,
columns: 4,
evaluate: 'evaluate.action',
commands: 'setViewportGridLayout',
},
},
{
id: 'Crosshairs',
uiType: 'ohif.toolButton',
props: {
icon: 'tool-crosshair',
label: i18n.t('Buttons:Crosshairs'),
commands: {
commandName: 'setToolActiveToolbar',
commandOptions: {
toolGroupIds: ['mpr'],
},
},
evaluate: {
name: 'evaluate.cornerstoneTool',
disabledText: i18n.t('Buttons:Select an MPR viewport to enable this tool'),
},
},
},
{
id: 'Reset',
uiType: 'ohif.toolButton',
props: {
icon: 'tool-reset',
label: i18n.t('Buttons:Reset View'),
tooltip: i18n.t('Buttons:Reset View'),
commands: 'resetViewport',
evaluate: 'evaluate.action',
},
},
{
id: 'rotate-right',
uiType: 'ohif.toolButton',
props: {
icon: 'tool-rotate-right',
label: i18n.t('Buttons:Rotate Right'),
tooltip: i18n.t('Buttons:Rotate +90'),
commands: 'rotateViewportCW',
evaluate: 'evaluate.action',
},
},
{
id: 'flipHorizontal',
uiType: 'ohif.toolButton',
props: {
icon: 'tool-flip-horizontal',
label: i18n.t('Buttons:Flip Horizontal'),
tooltip: i18n.t('Buttons:Flip Horizontally'),
commands: 'flipViewportHorizontal',
evaluate: [
'evaluate.viewportProperties.toggle',
{
name: 'evaluate.viewport.supported',
unsupportedViewportTypes: ['volume3d'],
},
],
},
},
{
id: 'ReferenceLines',
uiType: 'ohif.toolButton',
props: {
icon: 'tool-referenceLines',
label: i18n.t('Buttons:Reference Lines'),
tooltip: i18n.t('Buttons:Show Reference Lines'),
commands: 'toggleEnabledDisabledToolbar',
evaluate: 'evaluate.cornerstoneTool.toggle',
},
},
{
id: 'ImageOverlayViewer',
uiType: 'ohif.toolButton',
props: {
icon: 'toggle-dicom-overlay',
label: i18n.t('Buttons:Image Overlay'),
tooltip: i18n.t('Buttons:Toggle Image Overlay'),
commands: 'toggleEnabledDisabledToolbar',
evaluate: 'evaluate.cornerstoneTool.toggle',
},
},
{
id: 'StackScroll',
uiType: 'ohif.toolButton',
props: {
icon: 'tool-stack-scroll',
label: i18n.t('Buttons:Stack Scroll'),
tooltip: i18n.t('Buttons:Stack Scroll'),
commands: setToolActiveToolbar,
evaluate: 'evaluate.cornerstoneTool',
},
},
{
id: 'invert',
uiType: 'ohif.toolButton',
props: {
icon: 'tool-invert',
label: i18n.t('Buttons:Invert'),
tooltip: i18n.t('Buttons:Invert Colors'),
commands: 'invertViewport',
evaluate: 'evaluate.viewportProperties.toggle',
},
},
{
id: 'Cine',
uiType: 'ohif.toolButton',
props: {
icon: 'tool-cine',
label: i18n.t('Buttons:Cine'),
tooltip: i18n.t('Buttons:Cine'),
commands: 'toggleCine',
evaluate: [
'evaluate.cine',
{
name: 'evaluate.viewport.supported',
unsupportedViewportTypes: ['volume3d'],
},
],
},
},
{
id: 'Magnify',
uiType: 'ohif.toolButton',
props: {
icon: 'tool-magnify',
label: i18n.t('Buttons:Zoom-in'),
tooltip: i18n.t('Buttons:Zoom-in'),
commands: setToolActiveToolbar,
evaluate: 'evaluate.cornerstoneTool',
},
},
{
id: 'TagBrowser',
uiType: 'ohif.toolButton',
props: {
icon: 'dicom-tag-browser',
label: i18n.t('Buttons:Dicom Tag Browser'),
tooltip: i18n.t('Buttons:Dicom Tag Browser'),
commands: 'openDICOMTagViewer',
},
},
{
id: 'PlanarFreehandContourSegmentationTool',
uiType: 'ohif.toolBoxButton',
@ -559,7 +213,11 @@ export const toolbarButtons: Button[] = [
value: 'CatmullRomSplineROI',
label: i18n.t('Buttons:Catmull Rom Spline'),
},
{ id: 'LinearSplineROI', value: 'LinearSplineROI', label: i18n.t('Buttons:Linear Spline') },
{
id: 'LinearSplineROI',
value: 'LinearSplineROI',
label: i18n.t('Buttons:Linear Spline'),
},
{ id: 'BSplineROI', value: 'BSplineROI', label: i18n.t('Buttons:B-Spline') },
],
commands: {
@ -1195,4 +853,106 @@ export const toolbarButtons: Button[] = [
},
];
export default toolbarButtons;
/**
* The toolbox / utilities section wiring for segmentation editing. These are
* the sections rendered by the `panelSegmentationWithTools*` panels, so any
* mode that shows those panels can merge this block into its toolbar sections.
*/
export const segmentationToolboxSections: Record<string, string[]> = {
[TOOLBAR_SECTIONS.labelMapSegmentationToolbox]: ['LabelMapTools'],
[TOOLBAR_SECTIONS.contourSegmentationToolbox]: ['ContourTools'],
[TOOLBAR_SECTIONS.labelMapSegmentationUtilities]: ['LabelMapUtilities'],
[TOOLBAR_SECTIONS.contourSegmentationUtilities]: ['ContourUtilities'],
LabelMapTools: [
'LabelmapSlicePropagation',
'BrushTools',
'MarkerLabelmap',
'RegionSegmentPlus',
'Shapes',
'LabelMapEditWithContour',
],
ContourTools: [
'PlanarFreehandContourSegmentationTool',
'SculptorTool',
'SplineContourSegmentationTool',
'LivewireContourSegmentationTool',
],
LabelMapUtilities: ['InterpolateLabelmap', 'SegmentBidirectional'],
ContourUtilities: ['LogicalContourOperations', 'SimplifyContours', 'SmoothContours'],
BrushTools: ['Brush', 'Eraser', 'Threshold'],
};
/**
* The segmentation mode's main toolbar layout (primary bar and viewport
* action corners). Kept separate from the toolbox wiring above so other modes
* can adopt segmentation editing without adopting this mode layout.
*/
export const segmentationModeToolbarSections: Record<string, string[]> = {
[TOOLBAR_SECTIONS.primary]: [
'WindowLevel',
'Pan',
'Zoom',
'TrackballRotate',
'Capture',
'Layout',
'Crosshairs',
'MoreTools',
],
[TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'],
[TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'],
AdvancedRenderingControls: [
'windowLevelMenuEmbedded',
'voiManualControlMenu',
'Colorbar',
'opacityMenu',
'thresholdMenu',
],
[TOOLBAR_SECTIONS.viewportActionMenu.topRight]: [
'modalityLoadBadge',
'trackingStatus',
'navigationComponent',
],
[TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: ['windowLevelMenu'],
MoreTools: [
'Reset',
'rotate-right',
'flipHorizontal',
'ReferenceLines',
'ImageOverlayViewer',
'StackScroll',
'invert',
'Cine',
'Magnify',
'TagBrowser',
],
};
/**
* Segmentation capability packs registered (at default scope) by the
* cornerstone extension. These are pure "what can exist" packs and carry no
* mode identity:
* - `cornerstone.segmentationToolbarButtons` segmentation editing button definitions
* - `cornerstone.segmentationToolbarSections` toolbox/utilities section wiring
* - `cornerstone.segmentationModeToolbarSections` a reusable segmentation-mode toolbar layout
*
* Modes compose these by name in their own `toolbarButtons` /
* `toolbarSections` instance arrays; `?customization=` modules extend the
* result through the `mode` phase.
*/
const segmentationToolbarCustomization = {
'cornerstone.segmentationToolbarButtons': segmentationToolbarButtons,
'cornerstone.segmentationToolbarSections': segmentationToolboxSections,
'cornerstone.segmentationModeToolbarSections': segmentationModeToolbarSections,
};
export { segmentationToolbarButtons };
export default segmentationToolbarCustomization;

View File

@ -0,0 +1,204 @@
import { toolNames } from '../initCornerstoneTools';
import {
MIN_SEGMENTATION_DRAWING_RADIUS,
MAX_SEGMENTATION_DRAWING_RADIUS,
} from './segmentationToolbarCustomization';
/**
* Reusable tool group "capability blocks", registered as default
* customizations so modes and `?customization=` JSON modules can add them to
* a tool group by name via a mode's `toolGroupAdditions` customization, e.g.
*
* "mode": {
* "basic": {
* "toolGroupAdditions": {
* "default": { "$push": [{ "$reference": "cornerstone.segmentationTools" }] }
* }
* }
* }
*
* Each block is a `{ active/passive/enabled/disabled }` object suitable for
* `toolGroupService.addToolsToToolGroup`.
*/
function getToolGroupToolsCustomization({ commandsManager }) {
const brushInstances = [
{
toolName: 'CircularBrush',
parentTool: 'Brush',
configuration: {
activeStrategy: 'FILL_INSIDE_CIRCLE',
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
},
},
{
toolName: 'CircularEraser',
parentTool: 'Brush',
configuration: {
activeStrategy: 'ERASE_INSIDE_CIRCLE',
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
},
},
{
toolName: 'SphereBrush',
parentTool: 'Brush',
configuration: {
activeStrategy: 'FILL_INSIDE_SPHERE',
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
},
},
{
toolName: 'SphereEraser',
parentTool: 'Brush',
configuration: {
activeStrategy: 'ERASE_INSIDE_SPHERE',
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
},
},
{
toolName: 'ThresholdCircularBrush',
parentTool: 'Brush',
configuration: {
activeStrategy: 'THRESHOLD_INSIDE_CIRCLE',
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
},
},
{
toolName: 'ThresholdSphereBrush',
parentTool: 'Brush',
configuration: {
activeStrategy: 'THRESHOLD_INSIDE_SPHERE',
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
},
},
{
toolName: 'ThresholdCircularBrushDynamic',
parentTool: 'Brush',
configuration: {
activeStrategy: 'THRESHOLD_INSIDE_CIRCLE',
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
threshold: {
isDynamic: true,
dynamicRadius: 3,
},
},
},
{
toolName: 'ThresholdSphereBrushDynamic',
parentTool: 'Brush',
configuration: {
activeStrategy: 'THRESHOLD_INSIDE_SPHERE',
minRadius: MIN_SEGMENTATION_DRAWING_RADIUS,
maxRadius: MAX_SEGMENTATION_DRAWING_RADIUS,
threshold: {
isDynamic: true,
dynamicRadius: 3,
},
},
},
];
const splineInstances = [
{
toolName: 'CatmullRomSplineROI',
parentTool: toolNames.SplineContourSegmentation,
configuration: {
spline: {
type: 'CATMULLROM',
enableTwoPointPreview: true,
},
},
},
{
toolName: 'LinearSplineROI',
parentTool: toolNames.SplineContourSegmentation,
configuration: {
spline: {
type: 'LINEAR',
enableTwoPointPreview: true,
},
},
},
{
toolName: 'BSplineROI',
parentTool: toolNames.SplineContourSegmentation,
configuration: {
spline: {
type: 'BSPLINE',
enableTwoPointPreview: true,
},
},
},
];
return {
/**
* The segmentation editing tools (labelmap brushes/scissors and contour
* segmentation tools), matching the buttons in
* `cornerstone.segmentationToolbarButtons`.
*/
'cornerstone.segmentationTools': {
passive: [
...brushInstances,
{ toolName: toolNames.LabelmapSlicePropagation },
{ toolName: toolNames.MarkerLabelmap },
{ toolName: toolNames.RegionSegmentPlus },
{ toolName: toolNames.LabelMapEditWithContourTool },
{ toolName: toolNames.SegmentSelect },
{ toolName: toolNames.CircleScissors },
{ toolName: toolNames.RectangleScissors },
{ toolName: toolNames.SphereScissors },
{ toolName: toolNames.LivewireContourSegmentation },
{ toolName: toolNames.SculptorTool },
...splineInstances,
],
},
/**
* The measurement/annotation tools, matching the `MeasurementTools`
* buttons in `cornerstone.toolbarButtons`. Useful for adding annotations
* to modes (such as segmentation) whose tool groups omit them.
*/
'cornerstone.annotationTools': {
passive: [
{ toolName: toolNames.Length },
{
toolName: toolNames.ArrowAnnotate,
configuration: {
getTextCallback: (callback, eventDetails) => {
commandsManager.runCommand('arrowTextCallback', {
callback,
eventDetails,
});
},
changeTextCallback: (data, eventDetails, callback) => {
commandsManager.runCommand('arrowTextCallback', {
callback,
data,
eventDetails,
});
},
},
},
{ toolName: toolNames.Bidirectional },
{ toolName: toolNames.Probe },
{ toolName: toolNames.DragProbe },
{ toolName: toolNames.EllipticalROI },
{ toolName: toolNames.CircleROI },
{ toolName: toolNames.RectangleROI },
{ toolName: toolNames.Angle },
{ toolName: toolNames.CobbAngle },
{ toolName: toolNames.SplineROI },
{ toolName: toolNames.LivewireContour },
],
},
};
}
export default getToolGroupToolsCustomization;

View File

@ -784,12 +784,16 @@ export const toolbarSections = {
};
/**
* Customizations registered (at default scope) by the cornerstone extension:
* - `cornerstone.toolbarButtons` the default toolbar button definitions
* - `cornerstone.toolbarSections` the default toolbar layout (section -> button ids)
* Capability packs registered (at default scope) by the cornerstone extension:
* - `cornerstone.toolbarButtons` the general toolbar button definitions
* - `cornerstone.toolbarSections` the general toolbar layout (section -> button ids)
*
* Modes read these by name in `onModeEnter`; URL `?customization=` modules can
* extend them with immutability-helper commands (e.g. `$push` a new button).
* These are pure "what can exist" packs they carry no mode identity. Modes
* compose them with `{ $reference }` markers in their own `toolbarButtons` /
* `toolbarSections` instance arrays, which the mode route seeds into the Mode
* customization scope on enter; URL `?customization=` modules extend the result
* through the `mode` phase (e.g.
* `mode.basic.toolbarButtons: { $push: [{ $reference: '...' }] }`).
*/
const toolbarButtonsCustomization = {
'cornerstone.toolbarButtons': toolbarButtons,

View File

@ -9,6 +9,8 @@ import colorbarCustomization from './customizations/colorbarCustomization';
import modalityColorMapCustomization from './customizations/modalityColorMapCustomization';
import windowLevelPresetsCustomization from './customizations/windowLevelPresetsCustomization';
import toolbarButtonsCustomization from './customizations/toolbarButtonsCustomization';
import segmentationToolbarCustomization from './customizations/segmentationToolbarCustomization';
import getToolGroupToolsCustomization from './customizations/toolGroupToolsCustomization';
import miscCustomization from './customizations/miscCustomization';
import captureViewportModalCustomization from './customizations/captureViewportModalCustomization';
import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization';
@ -34,6 +36,8 @@ function getCustomizationModule({ commandsManager, servicesManager, extensionMan
...modalityColorMapCustomization,
...windowLevelPresetsCustomization,
...toolbarButtonsCustomization,
...segmentationToolbarCustomization,
...getToolGroupToolsCustomization({ commandsManager }),
...miscCustomization,
...captureViewportModalCustomization,
...viewportDownloadWarningCustomization,

View File

@ -1,7 +1,12 @@
import { toolGroupIds } from './initToolGroups';
import { ToolbarService } from '@ohif/core';
import i18n from 'i18next';
import { MIN_SEGMENTATION_DRAWING_RADIUS, MAX_SEGMENTATION_DRAWING_RADIUS } from './constants';
import { toolGroupIds } from '../toolGroupIds';
const { TOOLBAR_SECTIONS } = ToolbarService;
export const MIN_SEGMENTATION_DRAWING_RADIUS = 0.5;
export const MAX_SEGMENTATION_DRAWING_RADIUS = 99.5;
const setToolActiveToolbar = {
commandName: 'setToolActiveToolbar',
@ -10,6 +15,11 @@ const setToolActiveToolbar = {
},
};
/**
* Default toolbar buttons for the TMTV mode, registered as the
* `tmtv.toolbarButtons` customization so `?customization=` modules can extend
* or replace them.
*/
const toolbarButtons = [
{
id: 'MeasurementTools',
@ -301,7 +311,11 @@ const toolbarButtons = [
evaluate: [
{
name: 'evaluate.cornerstone.segmentation',
toolNames: ['ThresholdCircularBrush', 'ThresholdSphereBrush', 'ThresholdCircularBrushDynamic'],
toolNames: [
'ThresholdCircularBrush',
'ThresholdSphereBrush',
'ThresholdCircularBrushDynamic',
],
},
{
name: 'evaluate.cornerstone.segmentation.synchronizeDrawingRadius',
@ -458,4 +472,51 @@ const toolbarButtons = [
},
];
export default toolbarButtons;
/**
* Default toolbar layout for the TMTV mode, registered as the
* `tmtv.toolbarSections` customization.
*/
export const toolbarSections: Record<string, string[]> = {
[TOOLBAR_SECTIONS.primary]: ['MeasurementTools', 'Zoom', 'Pan', 'WindowLevel', 'Crosshairs'],
[TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'],
[TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'],
AdvancedRenderingControls: [
'windowLevelMenuEmbedded',
'voiManualControlMenu',
'Colorbar',
'opacityMenu',
'thresholdMenu',
],
[TOOLBAR_SECTIONS.viewportActionMenu.topRight]: [
'modalityLoadBadge',
'trackingStatus',
'navigationComponent',
],
[TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: ['windowLevelMenu'],
MeasurementTools: ['Length', 'Bidirectional', 'ArrowAnnotate', 'EllipticalROI'],
ROIThresholdToolbox: ['SegmentationTools'],
SegmentationTools: ['RectangleROIStartEndThreshold', 'BrushTools'],
BrushTools: ['Brush', 'Eraser', 'Threshold'],
};
/**
* TMTV-specific capability packs registered (at default scope) by the tmtv
* extension. These are the TMTV mode's specialized toolbar buttons and layout;
* the TMTV mode composes them by name in its own `toolbarButtons` /
* `toolbarSections` instance arrays, and `?customization=` modules extend the
* result through the `mode` phase.
*/
const toolbarCustomization = {
'tmtv.toolbarButtons': toolbarButtons,
'tmtv.toolbarSections': toolbarSections,
};
export { toolbarButtons };
export default toolbarCustomization;

View File

@ -0,0 +1,17 @@
import toolbarCustomization from './customizations/toolbarCustomization';
/**
* Registers the TMTV-specific capability packs (toolbar buttons and section
* layout) so the TMTV mode can compose them by name and `?customization=`
* modules can extend the result through the `mode` phase.
*/
export default function getCustomizationModule() {
return [
{
name: 'default',
value: {
...toolbarCustomization,
},
},
];
}

View File

@ -4,6 +4,8 @@ import getPanelModule from './getPanelModule';
import init from './init';
import commandsModule from './commandsModule';
import getToolbarModule from './getToolbarModule';
import getCustomizationModule from './getCustomizationModule';
import { toolGroupIds } from './toolGroupIds';
/**
*
@ -19,6 +21,7 @@ const tmtvExtension = {
getToolbarModule,
getPanelModule,
getHangingProtocolModule,
getCustomizationModule,
getCommandsModule({ servicesManager, commandsManager, extensionManager }) {
return commandsModule({
servicesManager,
@ -29,3 +32,4 @@ const tmtvExtension = {
};
export default tmtvExtension;
export { toolGroupIds };

View File

@ -0,0 +1,14 @@
/**
* The tool group ids used by the TMTV hanging protocols and mode. Defined in
* the extension so both the extension (hanging protocol viewports, toolbar
* buttons) and the mode (tool group creation) share one definition.
*/
export const toolGroupIds = {
CT: 'ctToolGroup',
PT: 'ptToolGroup',
Fusion: 'fusionToolGroup',
MIP: 'mipToolGroup',
default: 'default',
};
export default toolGroupIds;

View File

@ -40,6 +40,7 @@
},
"dependencies": {
"@babel/runtime": "7.29.7",
"@ohif/mode-basic": "workspace:*",
"i18next": "17.3.1"
},
"devDependencies": {

View File

@ -1,7 +1,17 @@
import i18n from 'i18next';
import { ToolbarService } from '@ohif/core';
import {
isValidMode,
layoutTemplate,
modeFactory,
modeInstance as basicModeInstance,
} from '@ohif/mode-basic';
import toolbarButtons from './toolbarButtons';
import { id } from './id';
import initToolGroups from './initToolGroups';
import i18n from 'i18next';
const { TOOLBAR_SECTIONS } = ToolbarService;
// Allow this mode by excluding non-imaging modalities such as SR, SEG
// Also, SM is not a simple imaging modalities, so exclude it.
@ -67,231 +77,178 @@ const extensionDependencies = {
'@ohif/extension-test': '^0.0.1',
};
function modeFactory() {
return {
id,
routeName: 'basic-test',
displayName: i18n.t('Modes:Basic Test Mode'),
/**
* Lifecycle hooks
*/
onModeEnter: ({ servicesManager, extensionManager, commandsManager }: withAppTypes) => {
const { measurementService, toolbarService, toolGroupService, customizationService } =
servicesManager.services;
/**
* The test mode's toolbar layout, supplied as literal values rather than
* `{ $reference }` capability-pack markers (the composition is resolved the
* same way either literals pass through untouched).
*/
const toolbarSections = {
[TOOLBAR_SECTIONS.primary]: [
'MeasurementTools',
'Zoom',
'WindowLevelGroup',
'Pan',
'Capture',
'Layout',
'MPR',
'Crosshairs',
'MoreTools',
],
measurementService.clearMeasurements();
WindowLevelGroup: ['WindowLevel', 'Soft tissue', 'Lung', 'Liver', 'Bone', 'Brain'],
// Init Default and SR ToolGroups
initToolGroups(extensionManager, toolGroupService, commandsManager);
[TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'],
// init customizations
customizationService.setCustomizations([
'@ohif/extension-test.customizationModule.custom-context-menu',
]);
[TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'],
toolbarService.register(toolbarButtons);
console.debug('toolbarButtons', toolbarButtons);
toolbarService.updateSection(toolbarService.sections.primary, [
'MeasurementTools',
'Zoom',
'WindowLevelGroup',
'Pan',
'Capture',
'Layout',
'MPR',
'Crosshairs',
'MoreTools',
]);
AdvancedRenderingControls: ['voiManualControlMenu', 'Colorbar', 'opacityMenu', 'thresholdMenu'],
toolbarService.updateSection('WindowLevelGroup', [
'WindowLevel',
'Soft tissue',
'Lung',
'Liver',
'Bone',
'Brain',
]);
[TOOLBAR_SECTIONS.viewportActionMenu.topRight]: [
'modalityLoadBadge',
'trackingStatus',
'navigationComponent',
],
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [
'orientationMenu',
'dataOverlayMenu',
]);
[TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: ['windowLevelMenu'],
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomMiddle, [
'AdvancedRenderingControls',
]);
MeasurementTools: [
'Length',
'Bidirectional',
'ArrowAnnotate',
'EllipticalROI',
'CircleROI',
'PlanarFreehandROI',
'SplineROI',
'LivewireContour',
],
toolbarService.updateSection('AdvancedRenderingControls', [
'voiManualControlMenu',
'Colorbar',
'opacityMenu',
'thresholdMenu',
]);
MoreTools: [
'Reset',
'rotate-right',
'flipHorizontal',
'ImageSliceSync',
'ReferenceLines',
'ImageOverlayViewer',
'StackScroll',
'invert',
'Probe',
'Cine',
'Angle',
'CobbAngle',
'Magnify',
'RectangleROI',
'CalibrationLine',
'TagBrowser',
'AdvancedMagnify',
'UltrasoundDirectionalTool',
'WindowLevelRegion',
],
};
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topRight, [
'modalityLoadBadge',
'trackingStatus',
'navigationComponent',
]);
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomLeft, [
'windowLevelMenu',
]);
toolbarService.updateSection('WindowLevelGroup', [
'WindowLevel',
'Soft tissue',
'Lung',
'Liver',
'Bone',
'Brain',
]);
toolbarService.updateSection('MeasurementTools', [
'Length',
'Bidirectional',
'ArrowAnnotate',
'EllipticalROI',
'CircleROI',
'PlanarFreehandROI',
'SplineROI',
'LivewireContour',
]);
toolbarService.updateSection('MoreTools', [
'Reset',
'rotate-right',
'flipHorizontal',
'ImageSliceSync',
'ReferenceLines',
'ImageOverlayViewer',
'StackScroll',
'invert',
'Probe',
'Cine',
'Angle',
'CobbAngle',
'Magnify',
'RectangleROI',
'CalibrationLine',
'TagBrowser',
'AdvancedMagnify',
'UltrasoundDirectionalTool',
'WindowLevelRegion',
]);
customizationService.setCustomizations(
{
'ohif.hotkeyBindings': {
$push: [
{
commandName: 'undo',
label: 'Undo',
keys: ['ctrl+z'],
isEditable: true,
},
],
},
},
'mode'
);
},
onModeExit: ({ servicesManager }: withAppTypes) => {
const {
toolGroupService,
syncGroupService,
segmentationService,
cornerstoneViewportService,
uiDialogService,
uiModalService,
} = servicesManager.services;
uiDialogService.hideAll();
uiModalService.hide();
toolGroupService.destroy();
syncGroupService.destroy();
segmentationService.destroy();
cornerstoneViewportService.destroy();
},
validationTags: {
study: [],
series: [],
},
isValidMode: function ({ modalities }) {
const modalities_list = modalities.split('\\');
// Exclude non-image modalities
return {
valid: !!modalities_list.filter(modality => NON_IMAGE_MODALITIES.indexOf(modality) === -1)
.length,
description:
'The mode does not support studies that ONLY include the following modalities: SM, ECG, SR, SEG',
};
},
routes: [
export const basicTestLayout = {
id: ohif.layout,
props: {
// Literal panel lists; the shared layout template also accepts
// customization names here.
leftPanels: [tracked.thumbnailList],
leftPanelResizable: true,
rightPanels: [cornerstone.panel, tracked.measurements, testExtension.measurements],
rightPanelResizable: true,
viewports: [
{
path: 'basic-test',
layoutTemplate: () => {
return {
id: ohif.layout,
props: {
leftPanels: [tracked.thumbnailList],
leftPanelResizable: true,
rightPanels: [cornerstone.panel, tracked.measurements, testExtension.measurements],
rightPanelResizable: true,
viewports: [
{
namespace: tracked.viewport,
displaySetsToDisplay: [
ohif.sopClassHandler,
dicomvideo.sopClassHandler,
ohif.wsiSopClassHandler,
],
},
{
namespace: dicomsr.viewport,
displaySetsToDisplay: [dicomsr.sopClassHandler, dicomsr.sopClassHandler3D],
},
{
namespace: dicompdf.viewport,
displaySetsToDisplay: [dicompdf.sopClassHandler],
},
{
namespace: dicomSeg.viewport,
displaySetsToDisplay: [dicomSeg.sopClassHandler],
},
{
namespace: dicomPmap.viewport,
displaySetsToDisplay: [dicomPmap.sopClassHandler],
},
],
},
};
},
namespace: tracked.viewport,
displaySetsToDisplay: [
ohif.sopClassHandler,
dicomvideo.sopClassHandler,
ohif.wsiSopClassHandler,
],
},
{
namespace: dicomsr.viewport,
displaySetsToDisplay: [dicomsr.sopClassHandler, dicomsr.sopClassHandler3D],
},
{
namespace: dicompdf.viewport,
displaySetsToDisplay: [dicompdf.sopClassHandler],
},
{
namespace: dicomSeg.viewport,
displaySetsToDisplay: [dicomSeg.sopClassHandler],
},
{
namespace: dicomPmap.viewport,
displaySetsToDisplay: [dicomPmap.sopClassHandler],
},
],
extensions: extensionDependencies,
hangingProtocol: 'default',
sopClassHandlers: [
dicomvideo.sopClassHandler,
dicomSeg.sopClassHandler,
ohif.wsiSopClassHandler,
ohif.sopClassHandler,
dicompdf.sopClassHandler,
dicomsr.sopClassHandler,
dicomsr.sopClassHandler3D,
],
hotkeys: {
name: 'basic-test-hotkeys',
},
};
export const basicTestRoute = {
path: 'basic-test',
layoutTemplate,
layoutInstance: basicTestLayout,
};
/**
* Extends the basic mode instance: the shared onModeEnter/onModeExit are
* inherited, and the test specifics (toolbar layout, tool groups, test
* customizations) are supplied as instance data.
*/
export const modeInstance = {
...basicModeInstance,
id,
routeName: 'basic-test',
displayName: i18n.t('Modes:Basic Test Mode'),
// Literal toolbar values instead of the basic mode's customization names.
toolbarButtons,
toolbarSections,
// Tool group setup used by the shared onModeEnter.
initToolGroups,
// The mode's own customizations, applied by the mode route as the bottom
// layer of the mode scope: the test extension's custom context menu, plus
// the undo hotkey used by the E2E tests. Given as a literal here; modes may
// also reference a registered block by name (see `basicModeCustomizations`).
modeCustomizations: [
'@ohif/extension-test.customizationModule.custom-context-menu',
{
'ohif.hotkeyBindings': {
$push: [
{
commandName: 'undo',
label: 'Undo',
keys: ['ctrl+z'],
isEditable: true,
},
],
},
},
};
}
],
isValidMode,
nonModeModalities: NON_IMAGE_MODALITIES,
routes: [basicTestRoute],
extensions: extensionDependencies,
hangingProtocol: 'default',
sopClassHandlers: [
dicomvideo.sopClassHandler,
dicomSeg.sopClassHandler,
ohif.wsiSopClassHandler,
ohif.sopClassHandler,
dicompdf.sopClassHandler,
dicomsr.sopClassHandler,
dicomsr.sopClassHandler3D,
],
hotkeys: {
name: 'basic-test-hotkeys',
},
};
const mode = {
id,
modeFactory,
modeInstance,
extensionDependencies,
};
export default mode;
export { initToolGroups };

View File

@ -267,7 +267,7 @@ function initVolume3DToolGroup(extensionManager, toolGroupService) {
toolGroupService.createToolGroupAndAddTools('volume3d', tools);
}
function initToolGroups(extensionManager, toolGroupService, commandsManager) {
function initToolGroups({ extensionManager, toolGroupService, commandsManager }) {
initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, 'default');
initSRToolGroup(extensionManager, toolGroupService, commandsManager);
initMPRToolGroup(extensionManager, toolGroupService, commandsManager);

View File

@ -2,6 +2,11 @@ import update from 'immutability-helper';
import { utils } from '@ohif/core';
import initToolGroups from './initToolGroups';
import {
addActivatePanelTriggers,
applyToolGroupAdditions,
registerModeToolbar,
} from './modeCustomization';
import { id } from './id';
const { structuredCloneWithFunctions } = utils;
@ -99,20 +104,48 @@ export const sopClassHandlers = [
];
/**
* Indicate this is a valid mode if:
* - it contains at least one of the modeModalities
* - it contains all of the array value in modeModalities
* Otherwise, if modeModalities is not defined:
* - it contains at least one modality other than the nonModeMOdalities.
* Data-driven mode validity check, using these mode instance properties:
* - `excludedStudies`: a list of `{ attribute: value }` objects; a study
* matching every attribute of any entry is invalid.
* - `excludedModalities`: the study is invalid when it contains ANY of these.
* - `modeModalities`: the study is valid when it contains at least one entry;
* an array entry requires all of its modalities to be present (e.g.
* `[['PT', 'CT']]` requires both PT and CT).
* - otherwise `nonModeModalities`: the study is valid when it contains at
* least one modality NOT in this list.
*/
export function isValidMode({ modalities }) {
export function isValidMode({ modalities, study }) {
const modalities_list = modalities.split('\\');
if (study && this.excludedStudies?.length) {
const excluded = this.excludedStudies.find(exclusion =>
Object.entries(exclusion).every(([key, value]) => study[key] === value)
);
if (excluded) {
return {
valid: false,
description: `The mode excludes studies matching: ${JSON.stringify(excluded)}`,
};
}
}
if (this.excludedModalities?.length) {
const excluded = this.excludedModalities.find(modality => modalities_list.includes(modality));
if (excluded) {
return {
valid: false,
description: `The mode does not support studies containing the ${excluded} modality`,
};
}
}
if (this.modeModalities?.length) {
for (const modeModality of this.modeModalities) {
if (Array.isArray(modeModality) && modeModality.every(m => modalities.indexOf(m) !== -1)) {
return { valid: true, description: `Matches ${modeModality.join(', ')}` };
} else if (modalities.indexOf(modeModality)) {
if (Array.isArray(modeModality)) {
if (modeModality.every(m => modalities_list.includes(m))) {
return { valid: true, description: `Matches ${modeModality.join(', ')}` };
}
} else if (modalities_list.includes(modeModality)) {
return { valid: true, description: `Matches ${modeModality}` };
}
}
@ -122,84 +155,81 @@ export function isValidMode({ modalities }) {
};
}
const nonModeModalities = this.nonModeModalities ?? [];
return {
valid: !!modalities_list.find(modality => this.nonModeModalities.indexOf(modality) === -1),
description: `The mode does not support studies that ONLY include the following modalities: ${this.nonModeModalities.join(', ')}`,
valid: !!modalities_list.find(modality => !nonModeModalities.includes(modality)),
description: `The mode does not support studies that ONLY include the following modalities: ${nonModeModalities.join(', ')}`,
};
}
export function onModeEnter({
servicesManager,
extensionManager,
commandsManager,
panelService,
segmentationService,
}: withAppTypes) {
/**
* The panel activation triggers the basic family of modes historically shipped
* (commented out): activate the segmentation/measurement panel when a
* segmentation/measurement is added. Not enabled by default; a mode or
* customization can set them via the `activatePanelTriggers` instance
* property.
*/
export const defaultActivatePanelTriggers = [
{
panelId: cornerstone.segmentation,
sourceServiceName: 'segmentationService',
sourceEvents: ['SEGMENTATION_ADDED'],
},
{
panelId: cornerstone.measurements,
sourceServiceName: 'measurementService',
sourceEvents: ['MEASUREMENT_ADDED', 'RAW_MEASUREMENT_ADDED'],
},
];
export function onModeEnter({ servicesManager, extensionManager, commandsManager }: withAppTypes) {
const { measurementService, toolbarService, toolGroupService, customizationService } =
servicesManager.services;
measurementService.clearMeasurements();
// Init Default and SR ToolGroups
initToolGroups(extensionManager, toolGroupService, commandsManager);
// Subscriptions the mode creates are tracked as unsubscribe functions on the
// instance; the shared onModeExit cleans them up. Extending modes push
// their own unsubscribe functions here after calling this function.
this._unsubscriptions = [];
// Toolbar buttons and layout may be supplied either as a customization name
// (a string, resolved through the customization service so `?customization=`
// modules can extend the cornerstone-registered defaults) or as a literal
// value (the button array / sections object) for modes that define them inline.
const resolveToolbarCustomization = (value: unknown) =>
typeof value === 'string' ? customizationService.getCustomization(value) : value;
// Init the mode's tool groups. The function is a mode instance property so
// extending modes can substitute their own tool group setup.
this.initToolGroups?.({ extensionManager, toolGroupService, commandsManager, servicesManager });
const toolbarButtons = resolveToolbarCustomization(this.toolbarButtons) as any;
const toolbarSections = (resolveToolbarCustomization(this.toolbarSections) ?? {}) as Record<
string,
string[]
>;
// Toolbar buttons and layout come from the mode's composition, which the
// mode route seeded onto the Mode customization scope on enter (the plain
// `toolbarButtons` / `toolbarSections` keys) and the app config / URL `mode`
// phase then layered on top. Reading them here — after that layering — lets
// `?customization=` modules extend the toolbar without the mode restating it.
registerModeToolbar(
{ toolbarService },
{
toolbarButtons: customizationService.getCustomization('toolbarButtons'),
toolbarSections: customizationService.getCustomization('toolbarSections'),
}
);
toolbarService.register(toolbarButtons);
// Extra tools (e.g. segmentation editing tools added by a customization) are
// layered onto the tool groups created above, from the resolved
// `toolGroupAdditions` composition (seeded on enter, refined by the `mode`
// phase).
applyToolGroupAdditions(
{ toolGroupService },
customizationService.getCustomization('toolGroupAdditions')
);
for (const [key, section] of Object.entries(toolbarSections)) {
toolbarService.updateSection(key, section);
}
// Note: the mode's `modeCustomizations` are NOT applied here — the mode
// route applies them right after the mode scope is reset, before the app
// config / URL `mode` phase blocks, so the final value of every key is
// decided purely by customization scope precedence and application order.
if (!this.enableSegmentationEdit) {
customizationService.setCustomizations({
'panelSegmentation.disableEditing': {
$set: true,
},
});
}
// // ActivatePanel event trigger for when a segmentation or measurement is added.
// // Do not force activation so as to respect the state the user may have left the UI in.
if (this.activatePanelTrigger) {
this._activatePanelTriggersSubscriptions = [
...panelService.addActivatePanelTriggers(
cornerstone.segmentation,
[
{
sourcePubSubService: segmentationService,
sourceEvents: [segmentationService.EVENTS.SEGMENTATION_ADDED],
},
],
true
),
...panelService.addActivatePanelTriggers(
cornerstone.measurements,
[
{
sourcePubSubService: measurementService,
sourceEvents: [
measurementService.EVENTS.MEASUREMENT_ADDED,
measurementService.EVENTS.RAW_MEASUREMENT_ADDED,
],
},
],
true
),
true,
];
}
// ActivatePanel event triggers (e.g. activating the segmentation panel when
// a segmentation is added). Off by default; supplied as data so extending
// modes and customizations can point at their own panels.
this._unsubscriptions.push(
...addActivatePanelTriggers({ servicesManager }, this.activatePanelTriggers)
);
}
export function onModeExit({ servicesManager }: withAppTypes) {
@ -212,8 +242,8 @@ export function onModeExit({ servicesManager }: withAppTypes) {
uiModalService,
} = servicesManager.services;
this._activatePanelTriggersSubscriptions.forEach(sub => sub.unsubscribe());
this._activatePanelTriggersSubscriptions.length = 0;
this._unsubscriptions?.forEach(unsubscribe => unsubscribe());
this._unsubscriptions = [];
uiDialogService.hideAll();
uiModalService.hide();
@ -226,6 +256,11 @@ export function onModeExit({ servicesManager }: withAppTypes) {
export const basicLayout = {
id: ohif.layout,
props: {
// Literal panel lists. The mode route seeds these into the standard
// `leftPanels` / `rightPanels` customizations at the bottom of
// the mode scope, so `mode` phase blocks and global customizations can
// modify them (e.g. swap in the segmentation panels with editing tools)
// before the sidebars resolve.
leftPanels: [ohif.thumbnailList],
leftPanelResizable: true,
rightPanels: [cornerstone.segmentation, cornerstone.measurements],
@ -284,11 +319,33 @@ export const modeInstance = {
// instance by default.
hide: false,
displayName: 'Non-Longitudinal Basic',
_activatePanelTriggersSubscriptions: [],
// Toolbar buttons and layout are referenced by customization name; the
// cornerstone extension registers the defaults and `?customization=` modules
// can extend them. onModeEnter resolves these names via the customization service.
toolbarSections: 'cornerstone.toolbarSections',
// Toolbar/tool-group composition: which capability packs this mode uses,
// named with `{ $reference }` markers the customization service expands at
// read time. The mode route seeds these onto the Mode customization scope on
// enter (as the plain `toolbarButtons` / `toolbarSections` /
// `toolGroupAdditions` keys), so `?customization=` modules extend them
// through the `mode` phase (e.g.
// `mode.basic.toolbarButtons: { $push: [{ $reference: '...' }] }`).
toolbarSections: [{ $reference: 'cornerstone.toolbarSections' }],
toolGroupAdditions: {
default: [],
mpr: [],
SRToolGroup: [],
volume3d: [],
},
// Tool group setup used by onModeEnter; extending modes can replace it.
initToolGroups,
// The mode's own customizations, referenced by name: the block is registered
// at default scope when the mode loads (see `customizations` below), and the
// mode route applies it as the bottom layer of the mode scope on enter.
// Later layers — the app config / URL `mode` phase blocks and any global
// customization (e.g. `segmentationEditing`) — override it purely by
// application order and scope precedence.
modeCustomizations: 'basicModeCustomizations',
// ActivatePanel event triggers, applied on mode enter. Empty by default so
// the state the user left the UI in is respected; extending modes or
// customizations can push `defaultActivatePanelTriggers` entries.
activatePanelTriggers: [],
/**
* Lifecycle hooks
@ -310,8 +367,7 @@ export const modeInstance = {
// general handler needs to come last. For this case, the dicomvideo must
// come first to remove video transfer syntax before ohif uses images
sopClassHandlers,
toolbarButtons: 'cornerstone.toolbarButtons',
enableSegmentationEdit: false,
toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }],
nonModeModalities: NON_IMAGE_MODALITIES,
};
@ -327,12 +383,35 @@ export function modeFactory({ modeConfiguration }) {
return modeInstance;
}
/**
* Customizations the mode registers with the customization service (Default
* scope) when it loads before the bootstrap phase applies, so bootstrap and
* `?customization=` modules can modify them before anything reads them.
* Values are plain data (registered customization values never carry `$`
* commands commands are how later customizations modify them).
*/
export const customizations = {
// The mode's own mode-scope block, applied by the mode route as the bottom
// layer of the mode scope on mode enter (see `modeCustomizations` above).
basicModeCustomizations: {
// Segmentation panel editing is off in the basic modes; e.g. the
// `segmentationEditing` customization overrides this at global scope.
'panelSegmentation.disableEditing': true,
},
};
export const mode = {
id,
modeFactory,
modeInstance: { ...modeInstance, hide: true },
extensionDependencies,
customizations,
};
export default mode;
export { initToolGroups };
export {
addActivatePanelTriggers,
applyToolGroupAdditions,
registerModeToolbar,
} from './modeCustomization';

View File

@ -83,10 +83,7 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage
{ toolName: toolNames.LivewireContour },
{ toolName: toolNames.WindowLevelRegion },
],
enabled: [
{ toolName: toolNames.ImageOverlayViewer },
{ toolName: toolNames.ReferenceLines },
],
enabled: [{ toolName: toolNames.ImageOverlayViewer }, { toolName: toolNames.ReferenceLines }],
disabled: [
{
toolName: toolNames.AdvancedMagnify,
@ -306,7 +303,12 @@ function initVolume3DToolGroup(extensionManager, toolGroupService) {
toolGroupService.createToolGroupAndAddTools('volume3d', tools);
}
function initToolGroups(extensionManager, toolGroupService, commandsManager) {
/**
* Mode tool group setup. All modes share this options-object signature so a
* mode (or a `modeConfiguration`) can substitute any other mode's
* implementation via the `initToolGroups` instance property.
*/
function initToolGroups({ extensionManager, toolGroupService, commandsManager }) {
initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, 'default');
initSRToolGroup(extensionManager, toolGroupService);
initMPRToolGroup(extensionManager, toolGroupService, commandsManager);

View File

@ -0,0 +1,101 @@
/**
* Helpers for applying a mode's toolbar / tool-group composition.
*
* The composition values (`toolbarButtons`, `toolbarSections`,
* `toolGroupAdditions`) are ordinary customizations resolved through the
* customization service. A mode lists the capability packs it uses with
* `{ $reference: '<name>' }` markers; the service expands those at read time
* (packs that are arrays are flattened into the surrounding list), and a site
* `?customization=` module extends or replaces them with immutability-helper
* commands (`$push` another `{ $reference }`, `$set` a hard-coded value, ...).
* By the time these helpers run, the values passed in are already fully
* resolved so they only need to shape/register them.
*/
/** Normalizes a value to an array (wrapping a single object, dropping nullish). */
function toArray(value: unknown): any[] {
if (value === undefined || value === null) {
return [];
}
return Array.isArray(value) ? value : [value];
}
/**
* Registers a mode's toolbar from its resolved composition.
*
* `toolbarButtons` is a flat list of button definitions registered with the
* toolbar service. `toolbarSections` is one or more
* `{ sectionKey: buttonIds[] }` objects shallow-merged in order (later values
* win per section) and applied with `updateSection`.
*/
export function registerModeToolbar({ toolbarService }, { toolbarButtons, toolbarSections }): void {
toolbarService.register(toArray(toolbarButtons));
const sections: Record<string, string[]> = Object.assign({}, ...toArray(toolbarSections));
for (const [key, section] of Object.entries(sections)) {
toolbarService.updateSection(key, section);
}
}
/**
* Adds extra tools to the tool groups a mode has already created.
*
* `toolGroupAdditions` is a resolved object mapping a tool group id to a list
* of tool blocks (each a `{ active/passive/enabled/disabled }` object). Tool
* groups the mode did not create are skipped, so a single additions object can
* be shared between modes with different tool group sets.
*/
export function applyToolGroupAdditions({ toolGroupService }, toolGroupAdditions): void {
if (!toolGroupAdditions) {
return;
}
for (const additions of toArray(toolGroupAdditions)) {
for (const [toolGroupId, toolBlocks] of Object.entries(additions)) {
if (!toolGroupService.getToolGroup(toolGroupId)) {
continue;
}
for (const tools of toArray(toolBlocks)) {
toolGroupService.addToolsToToolGroup(toolGroupId, tools);
}
}
}
}
/**
* Wires up a mode's ActivatePanel event triggers from data.
*
* `activatePanelTriggers` is a list of
* `{ panelId, sourceServiceName, sourceEvents, forceActive? }` entries;
* `sourceEvents` names are looked up in the source service's `EVENTS` map
* (falling back to the raw value) so the whole entry is JSON-serializable and
* can be supplied by a `?customization=` module or an extending mode.
*
* Returns the unsubscribe functions for the subscriptions created.
*/
export function addActivatePanelTriggers(
{ servicesManager },
activatePanelTriggers
): (() => void)[] {
const { panelService } = servicesManager.services;
const unsubscriptions: (() => void)[] = [];
for (const trigger of activatePanelTriggers ?? []) {
const { panelId, sourceServiceName, sourceEvents, forceActive = true } = trigger;
const sourcePubSubService = servicesManager.services[sourceServiceName];
if (!sourcePubSubService) {
console.warn(`addActivatePanelTriggers: no service registered for "${sourceServiceName}"`);
continue;
}
const subscriptions = panelService.addActivatePanelTriggers(
panelId,
[
{
sourcePubSubService,
sourceEvents: sourceEvents.map(name => sourcePubSubService.EVENTS?.[name] ?? name),
},
],
forceActive
);
unsubscriptions.push(...subscriptions.map(subscription => () => subscription.unsubscribe()));
}
return unsubscriptions;
}

View File

@ -1,7 +1,9 @@
import i18n from 'i18next';
import { id } from './id';
import { initToolGroups, cornerstone,
import {
initToolGroups,
ohif,
cornerstone,
dicomsr,
dicomvideo,
basicLayout,
@ -9,7 +11,7 @@ import { initToolGroups, cornerstone,
extensionDependencies as basicDependencies,
mode as basicMode,
modeInstance as basicModeInstance,
} from '@ohif/mode-basic';
} from '@ohif/mode-basic';
export const tracked = {
measurements: '@ohif/extension-measurement-tracking.panelModule.trackedMeasurements',
@ -28,6 +30,9 @@ export const longitudinalInstance = {
id: ohif.layout,
props: {
...basicLayout.props,
// Literal panel lists; the mode route seeds them into the standard
// `leftPanels` / `rightPanels` customizations so `mode` phase
// blocks and global customizations can modify them.
leftPanels: [tracked.thumbnailList],
rightPanels: [cornerstone.segmentation, tracked.measurements],
viewports: [
@ -37,33 +42,29 @@ export const longitudinalInstance = {
displaySetsToDisplay: basicLayout.props.viewports[0].displaySetsToDisplay,
},
...basicLayout.props.viewports,
],
}
};
],
},
};
export const longitudinalRoute =
{
...basicRoute,
path: 'longitudinal',
/*init: ({ servicesManager, extensionManager }) => {
export const longitudinalRoute = {
...basicRoute,
path: 'longitudinal',
/*init: ({ servicesManager, extensionManager }) => {
//defaultViewerRouteInit
},*/
layoutInstance: longitudinalInstance,
};
layoutInstance: longitudinalInstance,
};
export const modeInstance = {
...basicModeInstance,
// TODO: We're using this as a route segment
// We should not be.
id,
routeName: 'viewer',
displayName: i18n.t('Modes:Basic Viewer'),
routes: [
longitudinalRoute
],
extensions: extensionDependencies,
};
...basicModeInstance,
// TODO: We're using this as a route segment
// We should not be.
id,
routeName: 'viewer',
displayName: i18n.t('Modes:Basic Viewer'),
routes: [longitudinalRoute],
extensions: extensionDependencies,
};
const mode = {
...basicMode,

View File

@ -1,249 +1,144 @@
import { id } from './id';
import toolbarButtons from './toolbarButtons';
import initToolGroups from './initToolGroups';
import setUpAutoTabSwitchHandler from './utils/setUpAutoTabSwitchHandler';
import { ohif, cornerstone, extensionDependencies, dicomRT, segmentation } from '@ohif/mode-basic';
export * from './toolbarButtons';
import {
ohif,
cornerstone,
extensionDependencies,
dicomRT,
segmentation,
isValidMode,
onModeEnter as basicOnModeEnter,
onModeExit,
layoutTemplate,
modeFactory,
} from '@ohif/mode-basic';
function modeFactory({ modeConfiguration }) {
const _unsubscriptions = [];
return {
/**
* Mode ID, which should be unique among modes used by the viewer. This ID
* is used to identify the mode in the viewer's state.
*/
id,
routeName: 'segmentation',
/**
* Mode name, which is displayed in the viewer's UI in the workList, for the
* user to select the mode.
*/
displayName: 'Segmentation',
/**
* Runs when the Mode Route is mounted to the DOM. Usually used to initialize
* Services and other resources.
*/
onModeEnter: ({ servicesManager, extensionManager, commandsManager }: withAppTypes) => {
const {
measurementService,
toolbarService,
toolGroupService,
segmentationService,
viewportGridService,
panelService,
} = servicesManager.services;
/**
* Extends the basic mode enter with the segmentation panel auto tab switch
* handling (switching between labelmap/contour panels as segmentations of the
* relevant type become active).
*/
export function onModeEnter(ctx: withAppTypes) {
basicOnModeEnter.call(this, ctx);
measurementService.clearMeasurements();
const { segmentationService, viewportGridService, panelService } = ctx.servicesManager.services;
// Init Default and SR ToolGroups
initToolGroups(extensionManager, toolGroupService, commandsManager);
const { unsubscribeAutoTabSwitchEvents } = setUpAutoTabSwitchHandler({
segmentationService,
viewportGridService,
panelService,
});
toolbarService.register(toolbarButtons);
toolbarService.updateSection(toolbarService.sections.primary, [
'WindowLevel',
'Pan',
'Zoom',
'TrackballRotate',
'Capture',
'Layout',
'Crosshairs',
'MoreTools',
]);
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [
'orientationMenu',
'dataOverlayMenu',
]);
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomMiddle, [
'AdvancedRenderingControls',
]);
toolbarService.updateSection('AdvancedRenderingControls', [
'windowLevelMenuEmbedded',
'voiManualControlMenu',
'Colorbar',
'opacityMenu',
'thresholdMenu',
]);
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topRight, [
'modalityLoadBadge',
'trackingStatus',
'navigationComponent',
]);
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomLeft, [
'windowLevelMenu',
]);
toolbarService.updateSection('MoreTools', [
'Reset',
'rotate-right',
'flipHorizontal',
'ReferenceLines',
'ImageOverlayViewer',
'StackScroll',
'invert',
'Cine',
'Magnify',
'TagBrowser',
]);
toolbarService.updateSection(toolbarService.sections.labelMapSegmentationToolbox, [
'LabelMapTools',
]);
toolbarService.updateSection(toolbarService.sections.contourSegmentationToolbox, [
'ContourTools',
]);
toolbarService.updateSection('LabelMapTools', [
'LabelmapSlicePropagation',
'BrushTools',
'MarkerLabelmap',
'RegionSegmentPlus',
'Shapes',
'LabelMapEditWithContour',
]);
toolbarService.updateSection('ContourTools', [
'PlanarFreehandContourSegmentationTool',
'SculptorTool',
'SplineContourSegmentationTool',
'LivewireContourSegmentationTool',
]);
toolbarService.updateSection(toolbarService.sections.labelMapSegmentationUtilities, [
'LabelMapUtilities',
]);
toolbarService.updateSection(toolbarService.sections.contourSegmentationUtilities, [
'ContourUtilities',
]);
toolbarService.updateSection('LabelMapUtilities', [
'InterpolateLabelmap',
'SegmentBidirectional',
]);
toolbarService.updateSection('ContourUtilities', [
'LogicalContourOperations',
'SimplifyContours',
'SmoothContours',
]);
toolbarService.updateSection('BrushTools', ['Brush', 'Eraser', 'Threshold']);
const { unsubscribeAutoTabSwitchEvents } = setUpAutoTabSwitchHandler({
segmentationService,
viewportGridService,
panelService,
});
_unsubscriptions.push(...unsubscribeAutoTabSwitchEvents);
},
onModeExit: ({ servicesManager }: withAppTypes) => {
const {
toolGroupService,
syncGroupService,
segmentationService,
cornerstoneViewportService,
uiDialogService,
uiModalService,
} = servicesManager.services;
_unsubscriptions.forEach(unsubscribe => unsubscribe());
_unsubscriptions.length = 0;
uiDialogService.hideAll();
uiModalService.hide();
toolGroupService.destroy();
syncGroupService.destroy();
segmentationService.destroy();
cornerstoneViewportService.destroy();
},
/** */
validationTags: {
study: [],
series: [],
},
/**
* A boolean return value that indicates whether the mode is valid for the
* modalities of the selected studies. Currently we don't have stack viewport
* segmentations and we should exclude them
*/
isValidMode: ({ modalities }) => {
// Don't show the mode if the selected studies have only one modality
// that is not supported by the mode
const modalitiesArray = modalities.split('\\');
return {
valid:
modalitiesArray.length === 1
? !['SM', 'ECG', 'OT', 'DOC'].includes(modalitiesArray[0])
: true,
description:
'The mode does not support studies that ONLY include the following modalities: SM, OT, DOC',
};
},
/**
* Mode Routes are used to define the mode's behavior. A list of Mode Route
* that includes the mode's path and the layout to be used. The layout will
* include the components that are used in the layout. For instance, if the
* default layoutTemplate is used (id: '@ohif/extension-default.layoutTemplateModule.viewerLayout')
* it will include the leftPanels, rightPanels, and viewports. However, if
* you define another layoutTemplate that includes a Footer for instance,
* you should provide the Footer component here too. Note: We use Strings
* to reference the component's ID as they are registered in the internal
* ExtensionManager. The template for the string is:
* `${extensionId}.{moduleType}.${componentId}`.
*/
routes: [
{
path: 'template',
layoutTemplate: ({ location, servicesManager }) => {
return {
id: ohif.layout,
props: {
leftPanels: [ohif.thumbnailList],
leftPanelResizable: true,
rightPanels: [
cornerstone.labelMapSegmentationPanel,
cornerstone.contourSegmentationPanel,
],
rightPanelResizable: true,
// leftPanelClosed: true,
viewports: [
{
namespace: cornerstone.viewport,
displaySetsToDisplay: [ohif.sopClassHandler],
},
{
namespace: segmentation.viewport,
displaySetsToDisplay: [segmentation.sopClassHandler],
},
{
namespace: dicomRT.viewport,
displaySetsToDisplay: [dicomRT.sopClassHandler],
},
],
},
};
},
},
],
/** List of extensions that are used by the mode */
extensions: extensionDependencies,
/** HangingProtocol used by the mode */
// Commented out to just use the most applicable registered hanging protocol
// The example is used for a grid layout to specify that as a preferred layout
hangingProtocol: ['@ohif/mnGrid'],
/** SopClassHandlers used by the mode */
sopClassHandlers: [ohif.sopClassHandler, segmentation.sopClassHandler, dicomRT.sopClassHandler],
};
this._unsubscriptions.push(...unsubscribeAutoTabSwitchEvents);
}
export const segmentationLayout = {
id: ohif.layout,
props: {
// Literal panel lists; the mode route seeds them into the standard
// `leftPanels` / `rightPanels` customizations so `mode` phase
// blocks and global customizations can modify them.
leftPanels: [ohif.thumbnailList],
leftPanelResizable: true,
rightPanels: [cornerstone.labelMapSegmentationPanel, cornerstone.contourSegmentationPanel],
rightPanelResizable: true,
viewports: [
{
namespace: cornerstone.viewport,
displaySetsToDisplay: [ohif.sopClassHandler],
},
{
namespace: segmentation.viewport,
displaySetsToDisplay: [segmentation.sopClassHandler],
},
{
namespace: dicomRT.viewport,
displaySetsToDisplay: [dicomRT.sopClassHandler],
},
],
},
};
export const segmentationRoute = {
path: 'template',
layoutTemplate,
layoutInstance: segmentationLayout,
};
export const modeInstance = {
id,
routeName: 'segmentation',
displayName: 'Segmentation',
// Toolbar/tool-group composition: which capability packs this mode uses.
// The mode route seeds these onto the Mode customization scope on enter, so
// `?customization=` modules extend them through the `mode` phase (e.g. add
// the annotation tools/buttons). Pack names are resolved when the toolbar is
// registered.
toolbarButtons: [
{ $reference: 'cornerstone.toolbarButtons' },
{ $reference: 'cornerstone.segmentationToolbarButtons' },
],
toolbarSections: [
{ $reference: 'cornerstone.segmentationModeToolbarSections' },
{ $reference: 'cornerstone.segmentationToolbarSections' },
],
toolGroupAdditions: {
default: [],
mpr: [],
volume3d: [],
},
// Tool group setup used by onModeEnter; extending modes can replace it.
initToolGroups,
// The mode's own customizations, applied by the mode route as the bottom
// layer of the mode scope. Unlike basic, the registered block is empty (no
// `panelSegmentation.disableEditing`): the segmentation panel is editable.
modeCustomizations: 'segmentationModeCustomizations',
activatePanelTriggers: [],
/**
* Lifecycle hooks
*/
onModeEnter,
onModeExit,
validationTags: {
study: [],
series: [],
},
// Data-driven validity: valid unless the study ONLY contains modalities that
// segmentation cannot be performed on.
isValidMode,
nonModeModalities: ['SM', 'ECG', 'OT', 'DOC'],
routes: [segmentationRoute],
extensions: extensionDependencies,
// Prefer the grid layout hanging protocol when applicable.
hangingProtocol: ['@ohif/mnGrid'],
sopClassHandlers: [ohif.sopClassHandler, segmentation.sopClassHandler, dicomRT.sopClassHandler],
};
/**
* Customizations the mode registers (Default scope) when it loads. The mode's
* own block is empty the segmentation panel is editable in this mode but
* it is registered so bootstrap / `?customization=` modules can add
* mode-scoped values to it.
*/
export const customizations = {
segmentationModeCustomizations: {},
};
/**
* The mode uses the basic mode's `modeFactory`, which applies
* immutability-helper commands from `modeConfiguration` onto `modeInstance`,
* so a site can define a `mySegmentation` mode that extends this one.
*/
const mode = {
id,
modeFactory,
modeInstance,
extensionDependencies,
customizations,
};
export default mode;
export { initToolGroups };

View File

@ -258,7 +258,7 @@ function initVolume3DToolGroup(extensionManager, toolGroupService) {
toolGroupService.createToolGroupAndAddTools('volume3d', tools);
}
function initToolGroups(extensionManager, toolGroupService, commandsManager) {
function initToolGroups({ extensionManager, toolGroupService, commandsManager }) {
initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, 'default');
initMPRToolGroup(extensionManager, toolGroupService, commandsManager);
initVolume3DToolGroup(extensionManager, toolGroupService);

View File

@ -35,7 +35,9 @@
"@ohif/extension-default": "workspace:*",
"@ohif/extension-dicom-pdf": "workspace:*",
"@ohif/extension-dicom-video": "workspace:*",
"@ohif/extension-measurement-tracking": "workspace:*"
"@ohif/extension-measurement-tracking": "workspace:*",
"@ohif/extension-tmtv": "workspace:*",
"@ohif/mode-basic": "workspace:*"
},
"dependencies": {
"@babel/runtime": "7.29.7",

View File

@ -1,32 +1,39 @@
import { classes } from '@ohif/core';
import toolbarButtons from './toolbarButtons';
import {
isValidMode,
layoutTemplate,
modeFactory,
onModeEnter as basicOnModeEnter,
onModeExit,
} from '@ohif/mode-basic';
import i18n from 'i18next';
import { id } from './id.js';
import initToolGroups from './initToolGroups.js';
import initToolGroups, { toolGroupIds } from './initToolGroups.js';
import setCrosshairsConfiguration from './utils/setCrosshairsConfiguration.js';
import setFusionActiveVolume from './utils/setFusionActiveVolume.js';
import i18n from 'i18next';
const { MetadataProvider } = classes;
const ohif = {
export const ohif = {
layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack',
thumbnailList: '@ohif/extension-default.panelModule.seriesList',
};
const cs3d = {
export const cs3d = {
viewport: '@ohif/extension-cornerstone.viewportModule.cornerstone',
segPanel: '@ohif/extension-cornerstone.panelModule.panelSegmentationNoHeader',
measurements: '@ohif/extension-cornerstone.panelModule.measurements',
};
const tmtv = {
export const tmtv = {
hangingProtocol: '@ohif/extension-tmtv.hangingProtocolModule.ptCT',
petSUV: '@ohif/extension-tmtv.panelModule.petSUV',
tmtv: '@ohif/extension-tmtv.panelModule.tmtv',
};
const extensionDependencies = {
export const extensionDependencies = {
// Can derive the versions at least process.env.from npm_package_version
'@ohif/extension-default': '^3.0.0',
'@ohif/extension-cornerstone': '^3.0.0',
@ -34,234 +41,187 @@ const extensionDependencies = {
'@ohif/extension-tmtv': '^3.0.0',
};
const unsubscriptions = [];
function modeFactory({ modeConfiguration }) {
return {
// TODO: We're using this as a route segment
// We should not be.
id,
routeName: 'tmtv',
displayName: i18n.t('Modes:Total Metabolic Tumor Volume'),
/**
* Lifecycle hooks
*/
onModeEnter: ({ servicesManager, extensionManager, commandsManager }: withAppTypes) => {
const {
toolbarService,
toolGroupService,
customizationService,
hangingProtocolService,
displaySetService,
} = servicesManager.services;
/**
* Extends the basic mode enter (tool groups, toolbar, tool group additions)
* with the TMTV specifics: the fusion viewport crosshairs/active-volume
* configuration and the PT VOI hanging protocol attribute.
*/
export function onModeEnter(ctx: withAppTypes) {
basicOnModeEnter.call(this, ctx);
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.tools'
);
const { servicesManager, extensionManager, commandsManager } = ctx;
const { toolGroupService, customizationService, hangingProtocolService, displaySetService } =
servicesManager.services;
const { toolNames, Enums } = utilityModule.exports;
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.tools'
);
// Init Default and SR ToolGroups
initToolGroups(toolNames, Enums, toolGroupService, commandsManager);
const { toolNames } = utilityModule.exports;
const { unsubscribe } = toolGroupService.subscribe(
toolGroupService.EVENTS.VIEWPORT_ADDED,
() => {
// For fusion toolGroup we need to add the volumeIds for the crosshairs
// since in the fusion viewport we don't want both PT and CT to render MIP
// when slabThickness is modified
const { displaySetMatchDetails } = hangingProtocolService.getMatchDetails();
const { unsubscribe } = toolGroupService.subscribe(toolGroupService.EVENTS.VIEWPORT_ADDED, () => {
// For fusion toolGroup we need to add the volumeIds for the crosshairs
// since in the fusion viewport we don't want both PT and CT to render MIP
// when slabThickness is modified
const { displaySetMatchDetails } = hangingProtocolService.getMatchDetails();
setCrosshairsConfiguration(
displaySetMatchDetails,
toolNames,
toolGroupService,
displaySetService
);
setCrosshairsConfiguration(
displaySetMatchDetails,
toolNames,
toolGroupService,
displaySetService
);
setFusionActiveVolume(
displaySetMatchDetails,
toolNames,
toolGroupService,
displaySetService
);
}
);
setFusionActiveVolume(displaySetMatchDetails, toolNames, toolGroupService, displaySetService);
});
unsubscriptions.push(unsubscribe);
toolbarService.register(toolbarButtons);
toolbarService.updateSection(toolbarService.sections.primary, [
'MeasurementTools',
'Zoom',
'Pan',
'WindowLevel',
'Crosshairs',
]);
this._unsubscriptions.push(unsubscribe);
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [
'orientationMenu',
'dataOverlayMenu',
]);
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomMiddle, [
'AdvancedRenderingControls',
]);
toolbarService.updateSection('AdvancedRenderingControls', [
'windowLevelMenuEmbedded',
'voiManualControlMenu',
'Colorbar',
'opacityMenu',
'thresholdMenu',
]);
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topRight, [
'modalityLoadBadge',
'trackingStatus',
'navigationComponent',
]);
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomLeft, [
'windowLevelMenu',
]);
toolbarService.updateSection('MeasurementTools', [
'Length',
'Bidirectional',
'ArrowAnnotate',
'EllipticalROI',
]);
toolbarService.updateSection('ROIThresholdToolbox', ['SegmentationTools']);
toolbarService.updateSection('SegmentationTools', [
'RectangleROIStartEndThreshold',
'BrushTools',
]);
toolbarService.updateSection('BrushTools', ['Brush', 'Eraser', 'Threshold']);
customizationService.setCustomizations({
'panelSegmentation.tableMode': {
$set: 'expanded',
},
'panelSegmentation.onSegmentationAdd': {
$set: () => {
commandsManager.run('createNewLabelmapFromPT');
},
},
});
// 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
// code the window level in the hanging protocol but we add a custom
// attribute to the hanging protocol that will be used to get the
// window level based on the metadata
hangingProtocolService.addCustomAttribute(
'getPTVOIRange',
'get PT VOI based on corrected or not',
props => {
const ptDisplaySet = props.find(imageSet => imageSet.Modality === 'PT');
if (!ptDisplaySet) {
return;
}
const { imageId } = ptDisplaySet.images[0];
const imageIdScalingFactor = MetadataProvider.get('scalingModule', imageId);
const isSUVAvailable = imageIdScalingFactor && imageIdScalingFactor.suvbw;
if (isSUVAvailable) {
return {
windowWidth: 5,
windowCenter: 2.5,
};
}
return;
}
);
},
onModeExit: ({ servicesManager }: withAppTypes) => {
const {
toolGroupService,
syncGroupService,
segmentationService,
cornerstoneViewportService,
uiDialogService,
uiModalService,
} = servicesManager.services;
unsubscriptions.forEach(unsubscribe => unsubscribe());
uiDialogService.hideAll();
uiModalService.hide();
toolGroupService.destroy();
syncGroupService.destroy();
segmentationService.destroy();
cornerstoneViewportService.destroy();
},
validationTags: {
study: [],
series: [],
},
isValidMode: ({ modalities, study }) => {
const modalities_list = modalities.split('\\');
const invalidModalities = ['SM'];
const isValid =
modalities_list.includes('CT') &&
study.mrn !== 'M1' &&
modalities_list.includes('PT') &&
!invalidModalities.some(modality => modalities_list.includes(modality)) &&
// This is study is a 4D study with PT and CT and not a 3D study for the tmtv
// mode, until we have a better way to identify 4D studies we will use the
// StudyInstanceUID to identify the study
// Todo: when we add the 4D mode which comes with a mechanism to identify
// 4D studies we can use that
study.studyInstanceUid !== '1.3.6.1.4.1.12842.1.1.14.3.20220915.105557.468.2963630849';
// there should be both CT and PT modalities and the modality should not be SM
return {
valid: isValid,
description: 'The mode requires both PT and CT series in the study',
};
},
routes: [
{
path: 'tmtv',
/*init: ({ servicesManager, extensionManager }) => {
//defaultViewerRouteInit
},*/
layoutTemplate: () => {
return {
id: ohif.layout,
props: {
leftPanels: [ohif.thumbnailList],
leftPanelResizable: true,
leftPanelClosed: true,
rightPanels: [tmtv.tmtv, tmtv.petSUV],
rightPanelResizable: true,
viewports: [
{
namespace: cs3d.viewport,
displaySetsToDisplay: [ohif.sopClassHandler],
},
],
},
};
},
// Function-valued customization; kept out of the registered
// `tmtvModeCustomizations` block because it needs the mode's
// commandsManager. Written at mode scope, so a global-scope customization
// still overrides it by scope precedence.
customizationService.setCustomizations({
'panelSegmentation.onSegmentationAdd': {
$set: () => {
commandsManager.run('createNewLabelmapFromPT');
},
],
extensions: extensionDependencies,
hangingProtocol: tmtv.hangingProtocol,
sopClassHandlers: [ohif.sopClassHandler],
...modeConfiguration,
};
},
});
// 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
// code the window level in the hanging protocol but we add a custom
// attribute to the hanging protocol that will be used to get the
// window level based on the metadata
hangingProtocolService.addCustomAttribute(
'getPTVOIRange',
'get PT VOI based on corrected or not',
props => {
const ptDisplaySet = props.find(imageSet => imageSet.Modality === 'PT');
if (!ptDisplaySet) {
return;
}
const { imageId } = ptDisplaySet.images[0];
const imageIdScalingFactor = MetadataProvider.get('scalingModule', imageId);
const isSUVAvailable = imageIdScalingFactor && imageIdScalingFactor.suvbw;
if (isSUVAvailable) {
return {
windowWidth: 5,
windowCenter: 2.5,
};
}
return;
}
);
}
export const tmtvLayout = {
id: ohif.layout,
props: {
// Literal panel lists; the mode route seeds them into the standard
// `leftPanels` / `rightPanels` customizations so `mode` phase
// blocks and global customizations can modify them.
leftPanels: [ohif.thumbnailList],
leftPanelResizable: true,
leftPanelClosed: true,
rightPanels: [tmtv.tmtv, tmtv.petSUV],
rightPanelResizable: true,
viewports: [
{
namespace: cs3d.viewport,
displaySetsToDisplay: [ohif.sopClassHandler],
},
],
},
};
export const tmtvRoute = {
path: 'tmtv',
layoutTemplate,
layoutInstance: tmtvLayout,
};
export const modeInstance = {
// TODO: We're using this as a route segment
// We should not be.
id,
routeName: 'tmtv',
displayName: i18n.t('Modes:Total Metabolic Tumor Volume'),
// Toolbar/tool-group composition: which capability packs this mode uses.
// The mode route seeds these onto the Mode customization scope on enter, so
// `?customization=` modules extend them through the `mode` phase. The tmtv
// extension supplies the TMTV-specific button/section packs.
toolbarButtons: [{ $reference: 'tmtv.toolbarButtons' }],
toolbarSections: [{ $reference: 'tmtv.toolbarSections' }],
toolGroupAdditions: {
[toolGroupIds.CT]: [],
[toolGroupIds.PT]: [],
[toolGroupIds.Fusion]: [],
[toolGroupIds.MIP]: [],
[toolGroupIds.default]: [],
},
// Tool group setup used by onModeEnter; extending modes can replace it.
initToolGroups,
// The mode's own customizations, referenced by name: the block is registered
// at default scope when the mode loads (see `customizations` below), and the
// mode route applies it as the bottom layer of the mode scope on enter.
modeCustomizations: 'tmtvModeCustomizations',
activatePanelTriggers: [],
/**
* Lifecycle hooks
*/
onModeEnter,
onModeExit,
validationTags: {
study: [],
series: [],
},
// Data-driven validity: requires both PT and CT, rejects SM, and excludes
// the demo studies that belong to the preclinical 4D mode. Until we have a
// better way to identify 4D studies we use the mrn/StudyInstanceUID.
isValidMode,
modeModalities: [['PT', 'CT']],
excludedModalities: ['SM'],
excludedStudies: [
{ mrn: 'M1' },
{ studyInstanceUid: '1.3.6.1.4.1.12842.1.1.14.3.20220915.105557.468.2963630849' },
],
routes: [tmtvRoute],
extensions: extensionDependencies,
hangingProtocol: tmtv.hangingProtocol,
sopClassHandlers: [ohif.sopClassHandler],
};
/**
* Customizations the mode registers (Default scope) when it loads before
* the bootstrap phase applies, so bootstrap / `?customization=` modules can
* modify them before anything reads them. Values are plain data.
*/
export const customizations = {
tmtvModeCustomizations: {
'panelSegmentation.tableMode': 'expanded',
},
};
/**
* The mode uses the basic mode's `modeFactory`, which applies
* immutability-helper commands from `modeConfiguration` onto `modeInstance`,
* so a site can define a mode that extends this one.
*/
const mode = {
id,
modeFactory,
modeInstance,
extensionDependencies,
customizations,
};
export default mode;
export { initToolGroups };

View File

@ -1,12 +1,8 @@
import { toolGroupIds } from '@ohif/extension-tmtv';
import { MIN_SEGMENTATION_DRAWING_RADIUS, MAX_SEGMENTATION_DRAWING_RADIUS } from './constants';
export const toolGroupIds = {
CT: 'ctToolGroup',
PT: 'ptToolGroup',
Fusion: 'fusionToolGroup',
MIP: 'mipToolGroup',
default: 'default',
};
export { toolGroupIds };
function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager) {
const tools = {
@ -189,7 +185,16 @@ function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager) {
toolGroupService.createToolGroupAndAddTools(toolGroupIds.MIP, mipTools);
}
function initToolGroups(toolNames, Enums, toolGroupService, commandsManager) {
/**
* Mode tool group setup, sharing the options-object signature used by all
* modes so implementations are interchangeable via the `initToolGroups` mode
* instance property.
*/
function initToolGroups({ extensionManager, toolGroupService, commandsManager }) {
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.tools'
);
const { toolNames, Enums } = utilityModule.exports;
_initToolGroups(toolNames, Enums, toolGroupService, commandsManager);
}

View File

@ -28,9 +28,9 @@ window.config = {
// - mode: applied (Mode) on each mode enter — `*` (general) first,
// then a block keyed by the mode id / routeName.
customizationService: {
// Pulls in platform/app/public/customizations/patientBirthDate.jsonc, which
// adds a "Birth Date" column to the WorkList study list (global phase).
requires: ['patientBirthDate'],
// Pulls in platform/app/public/customizations/worklist/patientBirthDate.jsonc,
// which adds a "Birth Date" column to the WorkList study list (global phase).
requires: ['worklist/patientBirthDate'],
// The previous (legacy-array) customizations, now in the explicit `global`
// phase. A `global` block accepts the same input as setCustomizations: an

View File

@ -33,7 +33,7 @@ window.config = {
// are never executed. Example (left disabled here on purpose):
//
// customizationUrlPrefixes: {
// default: './customizations/', // ?customization=ctPresets
// default: './customizations/', // ?customization=tools/ctPresets
// '/remote/': 'https://cdn.example.com/ohif-custom/', // ?customization=/remote/siteA
// },
// ----------------------------------------------------------------------------

View File

@ -3,23 +3,26 @@
// Local development configuration.
//
// This is the default config for the dev server (`pnpm run dev`, `dev:fast`,
// `start`). Like config/netlify.js it is full-featured — every data source is
// enabled and the `?customization=` URL feature is ON via
// `customizationUrlPrefixes` — so the whole app can be exercised locally. The
// locked-down config/default.js is what a plain production build emits instead.
// `start`). It is intentionally kept at parity with config/netlify.js (the
// public demo deploy): every data source is enabled, the `?customization=` URL
// feature is ON via `customizationUrlPrefixes`, and the same startup
// `customizationService` modules are loaded — so the whole app, including
// customizations, can be exercised locally exactly as it runs on the demo.
// The locked-down config/default.js is what a plain production build emits
// instead.
window.config = {
name: 'config/dev.js',
routerBasename: null,
// whiteLabeling: {},
extensions: [],
modes: [],
customizationService: {},
customizationService: ['@ohif/extension-default.customizationModule.theme'],
// URL-driven customizations (?customization=). The `default` prefix (no
// slashes) is used for values without a leading slash; every other prefix
// must start AND end with a slash and matches the leading `/segment/` of the
// value. Files are fetched and parsed as JSONC data — never executed.
// e.g. ?customization=ctPresets -> ./customizations/ctPresets.jsonc
// e.g. ?customization=tools/ctPresets -> ./customizations/tools/ctPresets.jsonc
customizationUrlPrefixes: {
default: './customizations/',
},

View File

@ -21,7 +21,7 @@ window.config = {
extensions: [],
modes: ['@ohif/mode-test'],
// Allow the `default` prefix so e2e specs can exercise `?customization=` (e.g.
// Customization.spec.ts loads `?customization=veterinaryOverlay`).
// Customization.spec.ts loads `?customization=veterinary/veterinaryOverlay`).
customizationUrlPrefixes: {
default: './customizations/',
},

View File

@ -17,7 +17,7 @@ window.config = {
// slashes) is used for values without a leading slash; every other prefix
// must start AND end with a slash and matches the leading `/segment/` of the
// value. Files are fetched and parsed as JSONC data — never executed.
// e.g. ?customization=ctPresets -> ./customizations/ctPresets.jsonc
// e.g. ?customization=tools/ctPresets -> ./customizations/tools/ctPresets.jsonc
customizationUrlPrefixes: {
default: './customizations/',
},

View File

@ -0,0 +1,400 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>OHIF URL Customizations</title>
<style>
:root {
color-scheme: light dark;
--bg: #f5f6f8;
--card: #ffffff;
--card-border: #e3e6ea;
--text: #1a1f27;
--muted: #5c6774;
--accent: #2563eb;
--accent-contrast: #ffffff;
--code-bg: #eef1f5;
--tag-bg: #e6edff;
--tag-text: #2952cc;
--bar-bg: #ffffff;
}
/* This catalog page follows the OS light/dark preference automatically. */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0e1116;
--card: #171b22;
--card-border: #2a3038;
--text: #e6e9ee;
--muted: #99a3b0;
--accent: #4f8cff;
--accent-contrast: #0e1116;
--code-bg: #0b0e13;
--tag-bg: #1c2740;
--tag-text: #8fb2ff;
--bar-bg: #12161c;
}
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
padding-bottom: 160px;
}
.wrap { max-width: 900px; margin: 0 auto; padding: 32px 20px 20px; }
.topbar { display: flex; align-items: flex-start; gap: 16px; }
.topbar .titles { flex: 1; min-width: 0; }
.theme-picker { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.theme-picker label { font-size: 0.8rem; color: var(--muted); }
.theme-picker select {
background: var(--card); border: 1px solid var(--card-border); border-radius: 8px;
padding: 7px 10px; color: var(--text); font-size: 0.85rem; cursor: pointer;
}
header h1 { font-size: 1.7rem; margin: 0 0 6px; }
header p { color: var(--muted); margin: 0 0 8px; max-width: 62ch; }
code {
background: var(--code-bg);
border-radius: 4px;
padding: 1px 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.85em;
}
.group { margin-top: 28px; }
.group-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 4px; }
.group-head h2 { font-size: 1.15rem; margin: 0; }
.group-head .dir { color: var(--muted); font-size: 0.85rem; }
.group-head .toggle {
margin-left: auto; background: none; border: none; color: var(--accent);
cursor: pointer; font-size: 0.82rem; padding: 2px 4px;
}
.group-desc { color: var(--muted); font-size: 0.9rem; margin: 0 0 12px; }
.card {
display: flex; gap: 12px; align-items: flex-start;
background: var(--card); border: 1px solid var(--card-border);
border-radius: 10px; padding: 14px 16px; margin-bottom: 10px;
cursor: pointer; transition: border-color .12s ease;
}
.card:hover { border-color: var(--accent); }
.card input { margin-top: 3px; width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; }
.card .body { flex: 1; min-width: 0; }
.card .title-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.card .name { font-weight: 600; }
.card .param { color: var(--muted); font-size: 0.8rem; }
.card .desc { color: var(--muted); font-size: 0.9rem; margin: 4px 0 0; }
.tag {
font-size: 0.7rem; font-weight: 600; letter-spacing: .02em;
background: var(--tag-bg); color: var(--tag-text);
padding: 2px 7px; border-radius: 999px; text-transform: uppercase;
}
.launch {
position: fixed; bottom: 0; left: 0; right: 0;
background: var(--bar-bg); border-top: 1px solid var(--card-border);
box-shadow: 0 -4px 16px rgba(0,0,0,0.08);
}
.launch-inner { max-width: 900px; margin: 0 auto; padding: 14px 20px; }
.launch-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.launch-label { font-size: 0.8rem; color: var(--muted); }
.url-box {
flex: 1; min-width: 240px;
background: var(--code-bg); border: 1px solid var(--card-border); border-radius: 8px;
padding: 9px 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem; color: var(--text); overflow-x: auto; white-space: nowrap;
}
.base-input {
background: var(--card); border: 1px solid var(--card-border); border-radius: 8px;
padding: 8px 10px; color: var(--text); font-size: 0.85rem; width: 130px;
}
button.btn {
border: none; border-radius: 8px; padding: 9px 16px; font-size: 0.88rem; font-weight: 600;
cursor: pointer;
}
.btn-primary { background: var(--accent); color: var(--accent-contrast); }
.btn-primary[aria-disabled='true'] { opacity: .45; pointer-events: none; }
.btn-ghost { background: transparent; color: var(--accent); border: 1px solid var(--card-border); }
.count { font-size: 0.8rem; color: var(--muted); }
a { color: var(--accent); }
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="topbar">
<div class="titles">
<h1>URL Customizations</h1>
</div>
<div class="theme-picker">
<label for="viewer-theme">Viewer theme</label>
<select id="viewer-theme" title="OHIF viewer theme — added as ?theme= to the launch URL">
<optgroup label="Tonal (surfaces carry the theme hue)">
<option value="default">OHIF Blue (default)</option>
<option value="orchid">Orchid</option>
<option value="arctic">Arctic</option>
<option value="verdant">Verdant</option>
</optgroup>
<optgroup label="Neutral (desaturated + one accent)">
<option value="midnight">Midnight</option>
<option value="slate">Slate</option>
<option value="deep">Deep</option>
</optgroup>
</select>
</div>
</div>
<p>
These are the example <code>?customization=</code> profiles shipped under
<code>platform/app/public/customizations/</code>, grouped by area. Each file is fetched as
JSONC <strong>data</strong> (never executed) and applied at startup. Tick the ones you want,
optionally pick a viewer theme (added as <code>?theme=</code>), then launch the viewer or copy
the generated query string onto any viewer URL. Multiple profiles are comma-separated and
applied in order.
</p>
<p style="font-size: 0.85rem">
See the
<a href="https://docs.ohif.org/platform/services/customization-service/specificCustomizations">
customization docs</a>
for how to author your own. The prefix allowlist is set by
<code>customizationUrlPrefixes</code> in the app config.
</p>
</header>
<main id="groups"></main>
</div>
<div class="launch">
<div class="launch-inner">
<div class="launch-row" style="margin-bottom: 10px">
<span class="launch-label">Viewer base</span>
<input
id="base"
class="base-input"
value="../"
title="Base viewer URL the customization query is appended to (relative to this page)"
/>
<span class="count" id="count">0 selected</span>
<button class="btn btn-ghost" id="clear" type="button">Clear all</button>
<button class="btn btn-ghost" id="copy" type="button">Copy URL</button>
<a class="btn btn-primary" id="launch" href="#" aria-disabled="true">Launch viewer &rarr;</a>
</div>
<div class="launch-row">
<div class="url-box" id="url">Select one or more profiles above&hellip;</div>
</div>
</div>
</div>
<script>
// Catalog of shipped URL customizations, grouped by area/folder. The
// `value` is the `?customization=` token (folder-relative path, no `.jsonc`).
const CATALOG = [
{
dir: 'tools/',
title: 'Tools & presets',
desc: 'General viewport tools and presets. These apply globally (every mode).',
items: [
{
value: 'tools/ctPresets',
name: 'CT window/level presets',
scope: 'global',
desc: 'Replaces the CT presets in the window-level menu with a site-specific set (soft tissue, lung, angio, bone). Other modalities keep their defaults.',
},
{
value: 'tools/smoothRotate',
name: 'Smooth Rotate button',
scope: 'global',
desc: 'Adds a "Smooth Rotate" button to the More Tools menu that activates the PlanarRotate tool — drag to rotate the image freely (unlike the fixed 90° Rotate Right).',
},
{
value: 'tools/measurementLabels',
name: 'Predefined measurement labels',
scope: 'global',
desc: 'Prompts for a label from a fixed, exclusive list (Head, Shoulder, Knee, Toe) whenever a measurement is created.',
},
],
},
{
dir: 'worklist/',
title: 'Study list',
desc: 'Customizations for the WorkList / study browser.',
items: [
{
value: 'worklist/patientBirthDate',
name: 'Birth Date column',
scope: 'global',
desc: 'Adds a "Birth Date" column to the study list, immediately after the Patient column. Shipped configs load this by default via customizationService.requires.',
},
],
},
{
dir: 'segmentation/',
title: 'Segmentation',
desc: 'Per-mode composition of the segmentation capability packs.',
items: [
{
value: 'segmentation/segmentationEditing',
name: 'Segmentation editing',
scope: 'basic / longitudinal',
desc: 'Adds the segmentation editing toolbar, tools, and panels to the basic and longitudinal (viewer) modes, and enables editing in the segmentation panel.',
},
{
value: 'segmentation/segmentationAnnotationTools',
name: 'Annotation tools in segmentation mode',
scope: 'segmentation',
desc: 'Enables the measurement/annotation tools (Length, Bidirectional, ROIs, freehand, spline, livewire) inside the segmentation mode and adds the measurement panel.',
},
],
},
{
dir: 'veterinary/',
title: 'Veterinary',
desc: 'Veterinary-oriented overlay and a chaining profile that pulls it in.',
items: [
{
value: 'veterinary/veterinary',
name: 'Veterinary profile',
scope: 'global',
desc: 'Chaining profile — declares requires: ["veterinary/veterinaryOverlay"], so selecting this one alone pulls in and applies the overlay first.',
},
{
value: 'veterinary/veterinaryOverlay',
name: 'Veterinary viewport overlay',
scope: 'global',
desc: 'Overrides the viewport overlay with a veterinary-style demographics layout (patient/ID/date on the left, species/breed on the right).',
},
],
},
];
const groupsEl = document.getElementById('groups');
const urlEl = document.getElementById('url');
const launchEl = document.getElementById('launch');
const countEl = document.getElementById('count');
const baseEl = document.getElementById('base');
const viewerThemeEl = document.getElementById('viewer-theme');
CATALOG.forEach((group, gi) => {
const section = document.createElement('section');
section.className = 'group';
section.innerHTML =
'<div class="group-head">' +
'<h2>' + group.title + '</h2>' +
'<span class="dir"><code>' + group.dir + '</code></span>' +
'<button class="toggle" type="button" data-group="' + gi + '">Select all</button>' +
'</div>' +
'<p class="group-desc">' + group.desc + '</p>';
group.items.forEach(item => {
const label = document.createElement('label');
label.className = 'card';
label.innerHTML =
'<input type="checkbox" value="' + item.value + '" />' +
'<span class="body">' +
'<span class="title-row">' +
'<span class="name">' + item.name + '</span>' +
'<span class="tag">' + item.scope + '</span>' +
'<span class="param"><code>?customization=' + item.value + '</code></span>' +
'</span>' +
'<span class="desc">' + item.desc + '</span>' +
'</span>';
section.appendChild(label);
});
groupsEl.appendChild(section);
});
function selectedValues() {
return Array.from(document.querySelectorAll('input[type=checkbox]:checked')).map(c => c.value);
}
// Builds the launch URL from the selected profiles and viewer theme. The
// `theme=` param is only added for a non-default preset (default is the
// viewer's built-in look and needs no param). Returns null when there is
// nothing to launch.
function buildUrl(base, values, theme) {
// Sanitize the DOM-derived inputs before they reach the launch href.
// Customization tokens are `folder/name` segments, so allow word chars,
// dot, dash and slash only; the theme is a plain preset id. Anything with
// query metacharacters or a URL scheme (e.g. `javascript:`) is dropped.
const TOKEN = /^[\w.-]+(?:\/[\w.-]+)*$/;
const safeValues = values.filter(v => TOKEN.test(v));
const safeTheme = /^[\w.-]+$/.test(theme || '') ? theme : 'default';
if (!safeValues.length && (!safeTheme || safeTheme === 'default')) {
return null;
}
// Resolve the (hand-typed) base against the current page and require an
// http(s) target, so the base can never turn the href into a
// javascript:/data: URL. searchParams.set() encodes the values safely.
let parsed;
try {
parsed = new URL(base, window.location.href);
} catch (_) {
return null;
}
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
return null;
}
if (safeValues.length) {
parsed.searchParams.set('customization', safeValues.join(','));
}
if (safeTheme && safeTheme !== 'default') {
parsed.searchParams.set('theme', safeTheme);
}
return parsed.toString();
}
function refresh() {
const values = selectedValues();
countEl.textContent = values.length + ' selected';
const url = buildUrl(baseEl.value.trim() || '../', values, viewerThemeEl.value);
if (!url) {
urlEl.textContent = 'Select one or more profiles, or a viewer theme, above…';
launchEl.setAttribute('aria-disabled', 'true');
launchEl.removeAttribute('href');
return;
}
urlEl.textContent = url;
launchEl.href = url;
launchEl.setAttribute('aria-disabled', 'false');
}
groupsEl.addEventListener('change', refresh);
baseEl.addEventListener('input', refresh);
viewerThemeEl.addEventListener('change', refresh);
groupsEl.addEventListener('click', e => {
const btn = e.target.closest('.toggle');
if (!btn) return;
e.preventDefault();
const section = btn.closest('.group');
const boxes = section.querySelectorAll('input[type=checkbox]');
const allChecked = Array.from(boxes).every(b => b.checked);
boxes.forEach(b => (b.checked = !allChecked));
btn.textContent = allChecked ? 'Select all' : 'Clear';
refresh();
});
document.getElementById('clear').addEventListener('click', () => {
document.querySelectorAll('input[type=checkbox]').forEach(b => (b.checked = false));
document.querySelectorAll('.toggle').forEach(t => (t.textContent = 'Select all'));
refresh();
});
document.getElementById('copy').addEventListener('click', async () => {
const text = buildUrl(baseEl.value.trim() || '../', selectedValues(), viewerThemeEl.value);
if (!text) return;
try {
await navigator.clipboard.writeText(text);
const b = document.getElementById('copy');
const prev = b.textContent;
b.textContent = 'Copied!';
setTimeout(() => (b.textContent = prev), 1200);
} catch (_) {
/* clipboard unavailable — the URL is visible in the box to copy manually */
}
});
refresh();
</script>
</body>
</html>

View File

@ -0,0 +1,48 @@
// URL-loaded customization: segmentationAnnotationTools
//
// Enables the measurement/annotation tools inside the segmentation mode by
// composing capability packs the cornerstone extension already exports:
//
// - the annotation buttons are already registered by the mode (its
// `toolbarButtons` composition includes `cornerstone.toolbarButtons`), so
// the toolbar only needs a `MeasurementTools` section added to the primary
// bar,
// - the annotation tools themselves are added to the mode's tool groups via
// `cornerstone.annotationTools`,
// - and the measurement panel is appended so annotations can be reviewed.
//
// Everything is per-mode, so it all lives in the `mode` phase keyed by the
// segmentation mode's route name. The section tweak patches the mode's toolbar
// layout pack at Mode scope (reset on mode exit), and the tools/panels `$push`
// onto the values the mode seeds on enter.
//
// Load it with `?customization=segmentation/segmentationAnnotationTools`.
{
"mode": {
"segmentation": {
"cornerstone.segmentationModeToolbarSections": {
"primary": { "$unshift": ["MeasurementTools"] },
"MeasurementTools": {
"$set": [
"Length",
"Bidirectional",
"ArrowAnnotate",
"EllipticalROI",
"RectangleROI",
"CircleROI",
"PlanarFreehandROI",
"SplineROI",
"LivewireContour"
]
}
},
"toolGroupAdditions": {
"default": { "$push": [{ "$reference": "cornerstone.annotationTools" }] },
"mpr": { "$push": [{ "$reference": "cornerstone.annotationTools" }] }
},
"rightPanels": {
"$push": ["@ohif/extension-cornerstone.panelModule.panelMeasurement"]
}
}
}
}

View File

@ -0,0 +1,59 @@
// URL-loaded customization: segmentationEditing
//
// Adds segmentation editing to the basic and longitudinal (viewer) modes by
// composing the capability packs the cornerstone extension already exports:
//
// - the segmentation editing toolbar buttons and toolbox section wiring
// (`cornerstone.segmentationToolbarButtons` / `cornerstone.segmentationToolbarSections`),
// - the segmentation editing tools for the modes' tool groups
// (`cornerstone.segmentationTools`),
// - the segmentation panels that render the editing toolbox
// (`panelSegmentationWithToolsLabelMap` / `panelSegmentationWithToolsContour`),
// - and enables editing in the segmentation panel.
//
// Composition is per-mode, so every patch lives in the `mode` phase keyed by
// the target mode's route name (`basic`, `viewer`). Each mode seeds its own
// `toolbarButtons` / `toolbarSections` / `toolGroupAdditions` / `rightPanels`
// onto the Mode scope on enter; these blocks `$push` / `$set` on top of that,
// exactly like the panel swaps. No app-wide `global` keys are needed.
//
// Load it with `?customization=segmentation/segmentationEditing`.
{
"mode": {
"basic": {
"toolbarButtons": { "$push": [{ "$reference": "cornerstone.segmentationToolbarButtons" }] },
"toolbarSections": { "$push": [{ "$reference": "cornerstone.segmentationToolbarSections" }] },
"toolGroupAdditions": {
"default": { "$push": [{ "$reference": "cornerstone.segmentationTools" }] },
"mpr": { "$push": [{ "$reference": "cornerstone.segmentationTools" }] }
},
// The basic/longitudinal modes seed `panelSegmentation.disableEditing: true`
// from their own `modeCustomizations`; this `mode` phase value is applied
// after that (both Mode scope) and enables editing.
"panelSegmentation.disableEditing": { "$set": false },
"rightPanels": {
"$set": [
"@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsLabelMap",
"@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsContour",
"@ohif/extension-cornerstone.panelModule.panelMeasurement"
]
}
},
"viewer": {
"toolbarButtons": { "$push": [{ "$reference": "cornerstone.segmentationToolbarButtons" }] },
"toolbarSections": { "$push": [{ "$reference": "cornerstone.segmentationToolbarSections" }] },
"toolGroupAdditions": {
"default": { "$push": [{ "$reference": "cornerstone.segmentationTools" }] },
"mpr": { "$push": [{ "$reference": "cornerstone.segmentationTools" }] }
},
"panelSegmentation.disableEditing": { "$set": false },
"rightPanels": {
"$set": [
"@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsLabelMap",
"@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsContour",
"@ohif/extension-measurement-tracking.panelModule.trackedMeasurements"
]
}
}
}
}

View File

@ -4,7 +4,7 @@
// site-specific set (key: `cornerstone.windowLevelPresets`). Other modalities
// keep their defaults because only the `CT` entry is overridden.
//
// Load it with `?customization=ctPresets`.
// Load it with `?customization=tools/ctPresets`.
{
"global": {
"cornerstone.windowLevelPresets": {

View File

@ -4,7 +4,7 @@
// is created (key: `measurementLabels`). `labelOnMeasure` triggers the prompt on
// creation and `exclusive` restricts entries to the provided items.
//
// Load it with `?customization=measurementLabels`.
// Load it with `?customization=tools/measurementLabels`.
{
"global": {
"measurementLabels": {

View File

@ -8,7 +8,7 @@
// extension (`cornerstone.toolbarButtons` / `cornerstone.toolbarSections`) using
// immutability-helper `$push` commands, so the built-in buttons are preserved.
//
// Load it with `?customization=smoothRotate`.
// Load it with `?customization=tools/smoothRotate`.
{
"global": {
"cornerstone.toolbarButtons": {

View File

@ -1,5 +0,0 @@
// Example chaining module: ensures `veterinaryOverlay` is loaded and applied
// first when using `?customization=veterinary` alone.
{
"requires": ["veterinaryOverlay"]
}

View File

@ -0,0 +1,5 @@
// Example chaining module: ensures `veterinary/veterinaryOverlay` is loaded and
// applied first when using `?customization=veterinary/veterinary` alone.
{
"requires": ["veterinary/veterinaryOverlay"]
}

View File

@ -2,7 +2,7 @@
//
// Demonstrates a runtime-loaded customization that overrides the default
// viewport overlay with a veterinary-style demographics layout. Loaded via
// `?customization=veterinaryOverlay` (see CustomizationService URL handling).
// `?customization=veterinary/veterinaryOverlay` (see CustomizationService URL handling).
//
// Uses the same `global`-at-top-level shape as the cornerstone overlay samples
// and `inheritsFrom: 'ohif.overlayItem'` on each row, matching
@ -39,14 +39,14 @@
{
"id": "PatientSpecies",
"inheritsFrom": "ohif.overlayItem",
"attribute": "PatientSpecies",
"attribute": "PatientSpeciesDescription",
"label": "Species",
"title": "Patient species"
},
{
"id": "PatientBreed",
"inheritsFrom": "ohif.overlayItem",
"attribute": "PatientBreed",
"attribute": "PatientBreedDescription",
"label": "Breed",
"title": "Patient breed"
}

View File

@ -8,8 +8,8 @@
// tag 00100030).
//
// This applies in the `global` phase, so it is in place before the WorkList
// renders. Load it with `?customization=patientBirthDate`, or pull it in from
// `appConfig.customizationService.requires`.
// renders. Load it with `?customization=worklist/patientBirthDate`, or pull it in
// from `appConfig.customizationService.requires`.
{
"global": {
"workList.columns": {

View File

@ -93,13 +93,31 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
const loadedExtensions = await loadModules([...defaultExtensions, ...appConfig.extensions]);
const { customizationService } = servicesManager.services;
if (!appConfig.modes) {
throw new Error('No modes are defined! Check your app-config.js');
}
// Load the mode modules and register the customizations they carry (plain
// `customizationId -> value` maps on the mode definition) at Default scope
// BEFORE the bootstrap phase applies, so bootstrap / `?customization=`
// modules can modify a mode's registered values before anything reads them.
// The mode *instances* are only created after the global phase (below), so
// they too see any modifications.
const loadedModes = await loadModules([...(appConfig.modes || []), ...defaultModes]);
for (const mode of loadedModes) {
if (mode?.customizations) {
customizationService.addReferences(mode.customizations, customizationService.Scope.Default);
}
}
// Resolve every customization module up front — from
// `appConfig.customizationService.requires` and the `?customization=` URL
// parameter — long before any mode loads, then apply the `bootstrap` phase
// BEFORE extensions register so it is in place while they initialize. Modules
// are only loaded when `appConfig.customizationUrlPrefixes` allows their
// prefix; the feature is off by default, and a value with an unconfigured
// prefix throws here (aborting startup) rather than being silently ignored.
// parameter — then apply the `bootstrap` phase BEFORE extensions register so
// it is in place while they initialize. Modules are only loaded when
// `appConfig.customizationUrlPrefixes` allows their prefix; the feature is
// off by default, and a value with an unconfigured prefix throws here
// (aborting startup) rather than being silently ignored.
await customizationService.loadAndApplyBootstrapCustomizations(extensionManager);
await extensionManager.registerExtensions(loadedExtensions, appConfig.dataSources);
@ -114,12 +132,6 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
// TODO: We no longer init webWorkers at app level
// TODO: We no longer init the user Manager
if (!appConfig.modes) {
throw new Error('No modes are defined! Check your app-config.js');
}
const loadedModes = await loadModules([...(appConfig.modes || []), ...defaultModes]);
// This is the name for the loaded instance object
appConfig.loadedModes = [];
const modesById = new Set();

View File

@ -97,6 +97,15 @@ export default function ModeRoute({
}
}
// Mode dependency extensions register their customization modules here,
// but `registerExtension` does not merge them into the customization
// service — that otherwise only happens later in
// `extensionManager.onModeEnter`. Merge them now so anything that runs
// before setupRouteInit (e.g. a mode's layoutTemplate) can already read
// the defaults these extensions provide.
// `init` is idempotent — each extension module is merged at most once.
customizationService.init(extensionManager);
if (isMounted.current) {
setExtensionDependenciesLoaded(true);
}
@ -180,10 +189,19 @@ export default function ModeRoute({
if (isMounted.current) {
const { leftPanels = [], rightPanels = [], ...layoutProps } = layoutData.props;
// Register panels immediately so ViewerLayout's first render sees them.
// ResizablePanelsHook only auto-expands side panels on the initial mount;
// if panels are added later, the viewport grid keeps the wrong width.
// setupRouteInit (below) resets and re-applies them after customizations
// are layered on, so URL/config modules can still modify the lists.
panelService.reset();
panelService.addPanels(panelService.PanelPosition.Left, leftPanels);
panelService.addPanels(panelService.PanelPosition.Right, rightPanels);
// Stash the layout lists for setupRouteInit to seed into the
// `leftPanels` / `rightPanels` customizations.
layoutData.panels = { leftPanels, rightPanels };
// layoutProps contains all props but leftPanels and rightPanels
layoutData.props = layoutProps;
@ -222,11 +240,55 @@ export default function ModeRoute({
});
// `extensionManager.onModeEnter` resets the customization mode scope via
// `customizationService.onModeEnter`; now layer on the `mode` phase blocks
// for this mode — the general (`*`) block first, then any block keyed by
// this mode's id / routeName so a single mode can override the general one.
// `customizationService.onModeEnter`; the mode scope is then layered
// bottom-up so the final value of every key is decided by scope
// precedence (global > mode > default) and application order alone:
// 1. the mode's own values — its layout panel lists, seeded as the
// standard `leftPanels` / `rightPanels` customizations;
// its toolbar/tool-group composition, seeded as the plain
// `toolbarButtons` / `toolbarSections` / `toolGroupAdditions`
// customizations (resolved to concrete definitions later, when the
// mode's `onModeEnter` registers the toolbar); and its
// `modeCustomizations` block (declared as data on the mode
// instance, usually as a customization name registered at default
// scope when the mode loaded, so bootstrap/global customizations
// can modify the block itself before it is applied);
// 2. the app config / URL `mode` phase blocks — the general (`*`)
// block first, then any block keyed by this mode's id / routeName.
const { leftPanels = [], rightPanels = [] } = layoutTemplateData.current.panels ?? {};
customizationService.setCustomizations({
leftPanels,
rightPanels,
toolbarButtons: mode.toolbarButtons ?? [],
toolbarSections: mode.toolbarSections ?? [],
toolGroupAdditions: mode.toolGroupAdditions ?? {},
});
const modeCustomizations =
typeof mode.modeCustomizations === 'string'
? customizationService.getCustomization(mode.modeCustomizations)
: mode.modeCustomizations;
if (modeCustomizations) {
customizationService.setCustomizations(modeCustomizations);
}
customizationService.applyModeCustomizations([mode.id, mode.routeName]);
// Re-apply panels only when customizations changed the lists. When they
// match the layout, the panels registered in retrieveLayoutData are left
// in place so ViewerLayout's ResizablePanelsHook keeps the correct sizes
// from its one-time initial expand.
const resolvedLeftPanels = customizationService.getValue('leftPanels') ?? [];
const resolvedRightPanels = customizationService.getValue('rightPanels') ?? [];
const panelsChanged =
JSON.stringify(resolvedLeftPanels) !== JSON.stringify(leftPanels) ||
JSON.stringify(resolvedRightPanels) !== JSON.stringify(rightPanels);
if (panelsChanged) {
panelService.reset();
panelService.addPanels(panelService.PanelPosition.Left, resolvedLeftPanels);
panelService.addPanels(panelService.PanelPosition.Right, resolvedRightPanels);
}
// use the URL hangingProtocolId if it exists, otherwise use the one
// defined in the mode configuration
const hangingProtocolIdToUse = hangingProtocolService.getProtocolById(

View File

@ -13,6 +13,14 @@ describe('preserveQueryParameters', () => {
expect(out.getAll('configUrl')).toEqual(['foo.js']);
});
it('preserves the theme key across mode navigation', () => {
const current = new URLSearchParams();
current.append('theme', 'arctic');
const out = new URLSearchParams();
preserveQueryParameters(out, undefined, current);
expect(out.get('theme')).toBe('arctic');
});
it('preserves all repeated values for the customization key', () => {
const current = new URLSearchParams();
current.append('customization', 'a');

View File

@ -12,6 +12,7 @@ export const preserveKeys = [
'screenNumber',
'hangingProtocolId',
'customization',
'theme',
];
function preserveKey(query: URLSearchParams, current: URLSearchParams, key: string) {

View File

@ -0,0 +1,180 @@
import CustomizationService, { CustomizationScope } from './CustomizationService';
/**
* Tests for the `$reference` read-time resolution marker: composing
* customizations by name, flattening pack lists, and crucially replacing a
* reference wholesale with a subsequent `$set` (another reference or a
* hard-coded value).
*/
describe('CustomizationService $reference', () => {
let service: CustomizationService;
beforeEach(() => {
service = new CustomizationService({ configuration: {}, commandsManager: {} } as any);
// Capability packs registered at Default scope (as an extension would).
service.setCustomizations(
{
'cornerstone.toolbarButtons': [{ id: 'Length' }, { id: 'Pan' }],
'cornerstone.segTools': [{ id: 'Brush' }, { id: 'Eraser' }],
'other.toolbarButtons': [{ id: 'Zoom' }],
// Object-valued pack (a tool block), used to test references as an
// array item that resolves to a non-array (kept, not flattened).
'cornerstone.annotationBlock': { passive: [{ toolName: 'Length' }] },
},
CustomizationScope.Default
);
});
it('flattens a referenced array into the surrounding list', () => {
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Length' }, { id: 'Pan' }]);
});
it('composes multiple packs and mixes in literals', () => {
service.setCustomizations(
{
toolbarButtons: [
{ $reference: 'cornerstone.toolbarButtons' },
{ id: 'Custom' },
{ $reference: 'other.toolbarButtons' },
],
},
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toEqual([
{ id: 'Length' },
{ id: 'Pan' },
{ id: 'Custom' },
{ id: 'Zoom' },
]);
});
it('resolves references inside object property values (toolGroupAdditions map)', () => {
service.setCustomizations(
{
toolGroupAdditions: {
default: [{ $reference: 'cornerstone.annotationBlock' }],
mpr: [],
},
},
CustomizationScope.Mode
);
expect(service.getCustomization('toolGroupAdditions')).toEqual({
default: [{ passive: [{ toolName: 'Length' }] }],
mpr: [],
});
});
it('$push adds another reference that resolves and flattens', () => {
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
CustomizationScope.Mode
);
service.setCustomizations(
{ toolbarButtons: { $push: [{ $reference: 'cornerstone.segTools' }] } },
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toEqual([
{ id: 'Length' },
{ id: 'Pan' },
{ id: 'Brush' },
{ id: 'Eraser' },
]);
});
it('$set replaces a reference with a DIFFERENT reference', () => {
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
CustomizationScope.Mode
);
service.setCustomizations(
{ toolbarButtons: { $set: [{ $reference: 'other.toolbarButtons' }] } },
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Zoom' }]);
});
it('$set replaces a reference with a HARD-CODED list', () => {
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
CustomizationScope.Mode
);
service.setCustomizations(
{ toolbarButtons: { $set: [{ id: 'OnlyThis' }] } },
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'OnlyThis' }]);
});
it('a global-scope $set overrides a mode reference by scope precedence', () => {
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
CustomizationScope.Mode
);
service.setCustomizations(
{ toolbarButtons: { $set: [{ $reference: 'other.toolbarButtons' }] } },
CustomizationScope.Global
);
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Zoom' }]);
});
it('picks up live edits to the referenced pack', () => {
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }] },
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toHaveLength(2);
// Extend the pack itself; the referencing value reflects it on next read.
service.setCustomizations(
{ 'cornerstone.toolbarButtons': { $push: [{ id: 'Added' }] } },
CustomizationScope.Global
);
expect(service.getCustomization('toolbarButtons')).toEqual([
{ id: 'Length' },
{ id: 'Pan' },
{ id: 'Added' },
]);
});
it('resolves a whole-value reference (alias) and chains references', () => {
service.setCustomizations(
{
'alias.buttons': { $reference: 'cornerstone.toolbarButtons' },
toolbarButtons: [{ $reference: 'alias.buttons' }],
},
CustomizationScope.Mode
);
expect(service.getCustomization('alias.buttons')).toEqual([{ id: 'Length' }, { id: 'Pan' }]);
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Length' }, { id: 'Pan' }]);
});
it('breaks reference cycles instead of looping', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
service.setCustomizations(
{
cycleA: [{ $reference: 'cycleB' }],
cycleB: [{ $reference: 'cycleA' }],
},
CustomizationScope.Mode
);
// Should return without throwing; the cyclic branch resolves to nothing.
expect(() => service.getCustomization('cycleA')).not.toThrow();
expect(service.getCustomization('cycleA')).toEqual([]);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
it('warns and drops a reference to an unregistered customization', () => {
const warn = jest.spyOn(console, 'warn').mockImplementation(() => {});
service.setCustomizations(
{ toolbarButtons: [{ $reference: 'does.not.exist' }, { id: 'Kept' }] },
CustomizationScope.Mode
);
expect(service.getCustomization('toolbarButtons')).toEqual([{ id: 'Kept' }]);
expect(warn).toHaveBeenCalled();
warn.mockRestore();
});
});

View File

@ -484,13 +484,98 @@ export default class CustomizationService extends PubSubService {
this.globalCustomizations.get(customizationId) ??
this.modeCustomizations.get(customizationId) ??
this.defaultCustomizations.get(customizationId);
const newTransformed = this.transform(customization);
// Apply `inheritsFrom` / `$transform`, then expand any `$reference`
// markers (see `_resolveReferences`). `seen` starts with the id being read
// so a value that references itself is caught as a cycle.
const newTransformed = this._resolveReferences(
this.transform(customization),
new Set([customizationId])
);
if (newTransformed !== undefined) {
this.transformedCustomizations.set(customizationId, newTransformed);
}
return newTransformed;
}
/**
* Expands `$reference` markers inside a resolved customization value.
*
* A `{ $reference: '<name>' }` object is replaced by the value of the
* customization `<name>` (itself resolved recursively, so references can
* chain). References may appear anywhere in a value:
* - as the whole value an alias for another customization;
* - as an item in an **array** if the referenced value is itself an
* array it is spread (flattened) into the parent, so a list can compose
* several capability packs by name (e.g. a mode's `toolbarButtons`);
* - as a property value of a **plain object** (e.g. each list under a
* `toolGroupAdditions` map).
*
* Because expansion happens at read time (not when customizations are
* merged), a later `$set` replaces the reference wholesale with a different
* `{ $reference }` or a hard-coded value and edits to the referenced target
* are picked up live. Only plain arrays/objects are walked; class instances,
* functions and React elements are returned untouched, and unchanged values
* are returned by identity so non-referencing customizations are not cloned.
* Cycles are broken and warned via `seen`.
*/
private _resolveReferences(value: any, seen: Set<string>): any {
if (!value || typeof value !== 'object' || value.$$typeof) {
return value;
}
if (typeof value.$reference === 'string') {
return this._resolveReferenceName(value.$reference, seen);
}
if (Array.isArray(value)) {
let changed = false;
const result: any[] = [];
for (const item of value) {
if (item && typeof item === 'object' && !item.$$typeof && typeof item.$reference === 'string') {
changed = true;
const resolved = this._resolveReferenceName(item.$reference, seen);
if (Array.isArray(resolved)) {
result.push(...resolved);
} else if (resolved !== undefined) {
result.push(resolved);
}
} else {
const resolved = this._resolveReferences(item, seen);
changed ||= resolved !== item;
result.push(resolved);
}
}
return changed ? result : value;
}
if (!isPlainObject(value)) {
return value;
}
let changed = false;
const result: Record<string, any> = {};
for (const [key, val] of Object.entries(value)) {
const resolved = this._resolveReferences(val, seen);
changed ||= resolved !== val;
result[key] = resolved;
}
return changed ? result : value;
}
/** Resolves a single `$reference` target name, guarding against cycles. */
private _resolveReferenceName(name: string, seen: Set<string>): any {
if (seen.has(name)) {
console.warn(`CustomizationService: $reference cycle detected at "${name}"`);
return undefined;
}
const raw =
this.globalCustomizations.get(name) ??
this.modeCustomizations.get(name) ??
this.defaultCustomizations.get(name);
if (raw === undefined) {
console.warn(`CustomizationService: no customization registered for $reference "${name}"`);
return undefined;
}
const nextSeen = new Set(seen).add(name);
return this._resolveReferences(this.transform(raw), nextSeen);
}
/**
* Returns a customization value, or the provided fallback when unset.
*/
@ -1112,6 +1197,15 @@ export function normalizeCustomizationConfig(configuration: unknown): {
return {};
}
/** True for `{}`-literal / null-prototype objects (not arrays or class instances). */
function isPlainObject(value: any): boolean {
if (value === null || typeof value !== 'object') {
return false;
}
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
function hasDollarKey(value) {
if (Array.isArray(value)) {
for (const item of value) {
@ -1128,7 +1222,11 @@ function hasDollarKey(value) {
return false;
}
for (const key of Object.keys(value)) {
if (key.startsWith('$') && key !== '$transform') {
// `$transform` and `$reference` are read-time markers resolved by the
// service (in `transform` / `_resolveReferences`), not immutability-helper
// merge commands — so a value carrying them is stored verbatim rather than
// being run through `update()`.
if (key.startsWith('$') && key !== '$transform' && key !== '$reference') {
return true;
}
if (hasDollarKey(value[key])) {

View File

@ -26,7 +26,7 @@ allowlist.
window.config = {
customizationUrlPrefixes: {
// The `default` prefix (no slashes) handles values with no leading slash.
default: './customizations/', // ?customization=ctPresets -> ./customizations/ctPresets.jsonc
default: './customizations/', // ?customization=tools/ctPresets -> ./customizations/tools/ctPresets.jsonc
// Every other prefix MUST start and end with a slash and is matched against
// the leading `/segment/` of the value.
'/remote/': 'https://cdn.example.com/ohif-customizations/', // ?customization=/remote/siteA
@ -36,7 +36,7 @@ window.config = {
Resolution rules:
- `?customization=ctPresets` → `default` prefix → `./customizations/ctPresets.jsonc`
- `?customization=tools/ctPresets` → `default` prefix → `./customizations/tools/ctPresets.jsonc`
- `?customization=/remote/siteA``/remote/` prefix → `https://cdn.example.com/ohif-customizations/siteA.jsonc`
- **A value whose prefix is not configured throws and aborts startup** rather than
being silently ignored. With no `customizationUrlPrefixes` configured, *any*
@ -106,7 +106,7 @@ window.config = {
// in the global config, which is not itself updatable by any customization.
customizationUrlPrefixes: { default: './customizations/' },
customizationService: {
requires: ['patientBirthDate'], // resolves ./customizations/patientBirthDate.jsonc
requires: ['worklist/patientBirthDate'], // resolves ./customizations/worklist/patientBirthDate.jsonc
global: [ // mixes string references and inline maps
'@ohif/extension-default.customizationModule.datasources',
{ 'workList.variant': 'default' },
@ -179,13 +179,23 @@ or the full demo source list, you have two options:
## New `config/dev.js`; dev server no longer uses `default.js`
`config/default.js` is now **only** the default for a full production build. The
dev server gets a full-featured config instead, so `?customization=` and the
complete data-source list are available while developing without editing
`default.js`.
- **`config/dev.js`** (new) is the full-featured local-development config: every
data source enabled and `?customization=` turned on. The dev-server scripts
(`pnpm run dev`, `dev:fast`, `start`) now default to `config/dev.js`.
- **`config/netlify.js`** is the public demo / Netlify deploy config: the full
data source set plus `customizationUrlPrefixes: { default: './customizations/' }`.
- **`config/default.js`** remains the fallback for a real production build
(`pnpm run build` with no `APP_CONFIG`).
(`pnpm run dev`, `dev:fast`, `start`) now default to `config/dev.js`. It is
kept at **parity with `config/netlify.js`** — including the startup
`customizationService` modules (e.g. the appearance/theme customization) — so
customizations behave locally exactly as they do on the public demo.
- **`config/netlify.js`** is the public demo / Netlify deploy config
(`build:viewer:ci`): the full data source set plus
`customizationUrlPrefixes: { default: './customizations/' }` and the same
`customizationService` modules.
- **`config/default.js`** is the locked-down baseline and is the default **only**
for a real production build (`pnpm run build` with no `APP_CONFIG`).
### `APP_CONFIG` is honored, not clobbered

View File

@ -0,0 +1,179 @@
---
sidebar_position: 11
sidebar_label: Mode extensibility
title: 'Mode lifecycle regularization (basic / longitudinal / segmentation / tmtv)'
---
# Mode lifecycle regularization
3.13 makes the `segmentation`, `tmtv` and `basic-test` modes extend the `basic`
mode's shared lifecycle (as `longitudinal` already did), and in the process
regularizes a few mode-instance properties that 3.12 introduced. If you built a
mode on top of `@ohif/mode-basic` (or spread one of the shipped
`modeInstance` objects), review the changes below.
## Lifecycle ordering
Mode-related customizations follow one deterministic sequence; the final value
of every key is decided by scope precedence (global > mode > default) plus
application order, with no special cases:
1. app-config `requires` and `?customization=` chains are resolved up front;
2. mode modules load and register the `customizations` maps they carry
(Default scope);
3. the `bootstrap` phase applies (Global scope) — it can modify what the modes
registered;
4. extensions register; extension defaults merge; the `global` phase applies;
5. mode instances are created (`modeFactory`) — after bootstrap/global, so they
see modifications;
6. on mode enter, the mode scope is reset, then layered bottom-up: the mode's
layout panel lists (seeded as `leftPanels` / `rightPanels`) and its
toolbar/tool-group composition (seeded as the plain `toolbarButtons` /
`toolbarSections` / `toolGroupAdditions`), the mode's `modeCustomizations`
block, the `mode` phase `*` block, and the mode-specific block;
7. only then do the sidebars, toolbar, and `onModeEnter` consume the values.
## `initToolGroups` takes an options object
All modes now share one tool group setup signature, so an extending mode (or a
`modeConfiguration`) can substitute any other mode's implementation via the
`initToolGroups` instance property:
```js
// Before (3.12) — basic/segmentation form:
function initToolGroups(extensionManager, toolGroupService, commandsManager) { ... }
// Before (3.12) — tmtv form:
function initToolGroups(toolNames, Enums, toolGroupService, commandsManager) { ... }
// After (3.13) — every mode:
function initToolGroups({ extensionManager, toolGroupService, commandsManager, servicesManager }) {
// resolve toolNames/Enums yourself when needed:
const utilityModule = extensionManager.getModuleEntry(
'@ohif/extension-cornerstone.utilityModule.tools'
);
const { toolNames, Enums } = utilityModule.exports;
...
}
```
This only affects functions passed as the `initToolGroups` mode-instance
property (invoked by the shared `onModeEnter`); a self-contained mode that
calls its own function inside its own `onModeEnter` is unaffected.
## `enableSegmentationEdit` is replaced by `modeCustomizations`
Mode-scoped customizations are now declared as data instead of one-off boolean
capabilities, and the final value of every key is decided by the customization
service's normal resolution alone — scope precedence (global > mode > default)
plus application order within the mode scope — with no special-case logic.
On mode enter the mode route layers the mode scope bottom-up:
1. the mode's `modeCustomizations` block, applied right after the mode scope is
reset (e.g. `basicModeCustomizations` seeds `panelSegmentation.disableEditing: true`);
2. the app config / URL `mode` phase blocks (the general `*` block, then the
mode-specific block) — e.g. `?customization=segmentation/segmentationEditing` sets
`panelSegmentation.disableEditing: false` in its `mode.basic` / `mode.viewer`
blocks, which wins over step 1 by application order.
A `global`-scope customization still overrides the whole mode scope by scope
precedence when a value genuinely needs to apply to every mode.
The block itself is registered with the customization service at default scope
by the **mode** when it loads — modes carry a `customizations` map on their
definition, registered during app init *before* the bootstrap phase applies —
and the mode instance references it by name, so bootstrap and `?customization=`
modules can modify the block before it is ever applied:
```js
// Before (3.12)
export const modeInstance = {
enableSegmentationEdit: false,
};
// After (3.13) — the mode registers the block when it loads (plain
// key -> value data; registered customization values never carry `$`
// commands — commands are how later customizations modify them):
export const customizations = {
basicModeCustomizations: {
'panelSegmentation.disableEditing': true,
},
};
export const mode = {
id,
modeFactory,
modeInstance,
extensionDependencies,
customizations,
};
// and the mode instance references it:
export const modeInstance = {
modeCustomizations: 'basicModeCustomizations',
};
```
`modeCustomizations` may also be a literal value on the mode instance: an
object whose entries are plain values or immutability-helper commands (a
command merges with the value registered at default scope — e.g. the
`basic-test` mode `$push`es an extra hotkey onto `ohif.hotkeyBindings`), or an
array mixing customization module reference strings with such objects.
## `activatePanelTrigger` is replaced by data-driven `activatePanelTriggers`
The 3.12 `activatePanelTrigger` boolean (which hardcoded the cornerstone panel
ids) is replaced by an `activatePanelTriggers` list the shared `onModeEnter`
wires up. Entries are JSON-serializable — event names are looked up in the
source service's `EVENTS` map — so a customization or an extending mode can
point at its own panels:
```js
export const modeInstance = {
activatePanelTriggers: [
{
panelId: '@ohif/extension-cornerstone.panelModule.panelSegmentation',
sourceServiceName: 'segmentationService',
sourceEvents: ['SEGMENTATION_ADDED'],
},
],
};
```
It is empty by default (matching 3.12 behavior, where nothing set the boolean).
The basic mode exports `defaultActivatePanelTriggers` with the historical
segmentation/measurement panel triggers.
Relatedly, subscriptions created during `onModeEnter` are now tracked as
unsubscribe **functions** in a single `this._unsubscriptions` array
(initialized by the shared `onModeEnter`, cleaned by the shared `onModeExit`).
The `_activatePanelTriggersSubscriptions` array of subscription objects is
gone; extending modes push plain functions instead:
```js
export function onModeEnter(ctx) {
basicOnModeEnter.call(this, ctx);
const { unsubscribe } = someService.subscribe(...);
this._unsubscriptions.push(unsubscribe);
}
// No custom onModeExit needed — the shared one cleans up.
```
## Data-driven `isValidMode`
The shared `isValidMode` gained two properties alongside `modeModalities` /
`nonModeModalities`, letting modes declare validity without custom code:
- `excludedModalities`: the study is invalid when it contains **any** of these
(e.g. tmtv rejects `SM`).
- `excludedStudies`: a list of `{ attribute: value }` objects; a study matching
every attribute of an entry is invalid (e.g. `[{ mrn: 'M1' }]`).
Also note a 3.12 bug fix: `modeModalities` matching previously used a bare
`indexOf` truthiness check, which inverted matches in some cases; it now
correctly tests list membership.
## tmtv now clears measurements on enter
Because tmtv shares the basic mode's `onModeEnter`, it now calls
`measurementService.clearMeasurements()` on mode entry like every other mode.

View File

@ -0,0 +1,78 @@
---
sidebar_position: 10
sidebar_label: Mode panel lists & customization
title: 'Mode panel lists are standard customizations'
---
# Mode panel lists are standard customizations
3.13 lets a mode's sidebars be modified at runtime through the customization
service — for **every** mode, with nothing to opt into. Modes declare their
panels the standard way, as literal arrays in the layout:
```ts
props: {
leftPanels: ['@ohif/extension-default.panelModule.seriesList'],
rightPanels: ['@ohif/extension-cornerstone.panelModule.panelMeasurement'],
}
```
On mode enter the mode route layers the mode scope bottom-up and only then
resolves the sidebars:
1. the mode scope is reset;
2. the layout's panel arrays are seeded as the standard `leftPanels` /
`rightPanels` customizations (the bottom layer of the mode scope);
3. the app config / URL `mode` phase blocks apply — the general `*` block, then
the block keyed by the entered mode's id / route name;
4. the sidebars resolve from the final `leftPanels` / `rightPanels`
values (global-scope customizations, as always, win by scope precedence).
## Customizing a mode's panels
Because the mode's own list is already in the customization service when the
phase blocks apply, a `?customization=` module (or `window.config`
customization) targets the standard keys in a `mode` phase block — and
immutability-helper commands compose with the mode's own list:
```jsonc
{
"mode": {
// Replace the right sidebar in the longitudinal mode (route name `viewer`)
"viewer": {
"rightPanels": {
"$set": [
"@ohif/extension-cornerstone.panelModule.panelSegmentationWithToolsLabelMap",
"@ohif/extension-measurement-tracking.panelModule.trackedMeasurements"
]
}
},
// Append a panel in the segmentation mode
"segmentation": {
"rightPanels": {
"$push": ["@ohif/extension-cornerstone.panelModule.panelMeasurement"]
}
},
// Or change every mode at once with the general block
"*": {
"leftPanels": { "$push": ["@ohif/extension-example.panelModule.myPanel"] }
}
}
}
```
See the shipped
[`segmentation/segmentationEditing.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/segmentation/segmentationEditing.jsonc)
and
[`segmentation/segmentationAnnotationTools.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/segmentation/segmentationAnnotationTools.jsonc)
modules for complete worked examples.
## Migration notes
- **Existing modes need no changes.** Literal panel arrays are the standard
form and are now also the customizable form.
- **The per-mode panel-list names from early 3.13 betas are gone.** If you
wrote a customization against `basic.leftPanels`, `longitudinal.rightPanels`,
`segmentation.rightPanels`, or `tmtv.leftPanels`, move it to the standard
`leftPanels` / `rightPanels` keys inside a `mode` phase block keyed
by the mode's route name (see above).

View File

@ -225,15 +225,51 @@ export default mode;
</tr>
<tr>
<td align="left">
enableSegmentationEdit
excludedModalities
</td>
<td align="left">Boolean to skip the segmentation edit capabilities</td>
<td align="left">The default isValidMode returns false when the modalities list contains ANY of these</td>
</tr>
<tr>
<td align="left">
excludedStudies
</td>
<td align="left">A list of study attribute objects; the default isValidMode returns false for a study matching every attribute of any entry, e.g. <code>[&#123; mrn: 'M1' &#125;]</code></td>
</tr>
<tr>
<td align="left">
toolbarSections
</td>
<td align="left">An object containing toolbar section definitions to register</td>
<td align="left">Toolbar section composition: a list of section-layout packs (as <code>$reference</code> markers) and/or literal section objects; seeded onto the Mode scope on enter</td>
</tr>
<tr>
<td align="left">
toolbarButtons
</td>
<td align="left">Toolbar button composition: a list of button packs (as <code>$reference</code> markers) and/or literal button definitions; seeded onto the Mode scope on enter</td>
</tr>
<tr>
<td align="left">
toolGroupAdditions
</td>
<td align="left">Per-tool-group composition: a map of tool-group id to a list of tool packs (as <code>$reference</code> markers) and/or literal tool blocks, layered onto the mode's tool groups after creation</td>
</tr>
<tr>
<td align="left">
initToolGroups
</td>
<td align="left">Tool group setup function called by the shared onModeEnter as <code>initToolGroups(&#123; extensionManager, toolGroupService, commandsManager, servicesManager &#125;)</code>; extending modes can substitute their own</td>
</tr>
<tr>
<td align="left">
modeCustomizations
</td>
<td align="left">The mode's own customizations, applied by the mode route as the bottom layer of the mode scope on enter — before the app config / URL <code>mode</code> phase blocks, and below global-scope customizations, so final values are decided purely by scope precedence and application order. Usually the name of a block the extension registers at default scope (e.g. <code>basicModeCustomizations</code>, which sets <code>panelSegmentation.disableEditing</code>); may also be a literal object of immutability-helper commands or an array mixing those with customization module reference strings</td>
</tr>
<tr>
<td align="left">
activatePanelTriggers
</td>
<td align="left">Data-driven ActivatePanel event triggers: a list of <code>&#123; panelId, sourceServiceName, sourceEvents, forceActive? &#125;</code> entries wired up on mode enter (e.g. activating the segmentation panel when a segmentation is added). Empty by default; see <code>defaultActivatePanelTriggers</code> in the basic mode</td>
</tr>
@ -249,6 +285,73 @@ some default functions which can be used to create your own modes. Doing a mode
this way makes the definition of new modes based on your existing mode much easier,
and the upgrade to new versions of modes tends to be more consistent.
The **`segmentation`** and **`tmtv`** modes now follow this same pattern. Like
`longitudinal`, each one exports a `modeInstance` object and reuses the `basic`
mode's `modeFactory`, so they are extensible in two complementary ways:
1. **Build a derived mode** — create a new mode package (for example
`mySegmentation`) that imports the shipped mode and overrides only the parts
you need, exactly like `longitudinal` builds on `basic`.
2. **Customize an existing mode at runtime** — change a shipped mode's toolbar,
tools, or panels through per-mode customization keys, with no new package.
#### Building a derived mode
A derived mode imports the shipped mode's default export (which carries the
`modeFactory`) and its `modeInstance`, then spreads and overrides. Because the
default `modeFactory` applies [immutability-helper][immutability-helper]
commands from `modeConfiguration` onto `modeInstance`, you can also override
via `modeConfiguration` rather than editing the instance directly.
```js title="modes/my-segmentation/src/index.tsx"
import segmentationMode, { modeInstance as segModeInstance } from '@ohif/mode-segmentation';
const id = 'mySegmentation';
export const modeInstance = {
...segModeInstance,
id,
routeName: 'mySegmentation',
displayName: 'My Segmentation',
// Override only what you need. Toolbar buttons/sections and tool group
// additions are plain composition arrays on the instance naming the
// capability packs the mode uses (see below); panel lists are literal
// arrays in the layout. Both are customized at runtime via the `mode` phase.
};
const mode = {
...segmentationMode, // carries modeFactory + extensionDependencies
id,
modeInstance,
};
export default mode;
```
The `tmtv` mode is extended the same way — import `@ohif/mode-tmtv` and its
`modeInstance`, then override.
#### Customizing a mode at runtime
The `basic`, `longitudinal`, `segmentation`, and `tmtv` modes declare their toolbar
buttons, toolbar sections, and tool-group additions as plain composition arrays on
the mode instance that name the capability packs the mode uses with `{ $reference }`
markers (for example
`toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }, { $reference: 'cornerstone.segmentationToolbarButtons' }]`).
The mode route seeds these onto the Mode customization scope on enter, alongside the
`leftPanels` / `rightPanels` panel lists. Because the values are lists whose
`{ $reference }` entries the customization service expands at read time, a `window.config`
entry or a `?customization=` JSON module can add a whole capability pack (such as the
segmentation editing tools), remove a default, or swap the panels — targeting the mode
through a `mode` phase block (`mode.basic`, `mode.segmentation`, ...) without building
a new mode. See [Compose whole capability blocks into a mode][compose-capability-blocks] in the
Customization Service docs for the full key table, the reusable capability
blocks, and worked examples (adding segmentation editing to the basic and
longitudinal modes, and enabling annotation tools inside the segmentation mode).
[immutability-helper]: https://github.com/kolodny/immutability-helper
[compose-capability-blocks]: ../services/customization-service/specificCustomizations.md#4-compose-whole-capability-blocks-into-a-mode
### Consuming Extensions
As mentioned in the [Extensions](../extensions/index.md) section, in `OHIF-v3`

View File

@ -107,10 +107,13 @@ A URL-loaded file applies its `global` payload as **global customizations** —
`window.config`'s `customizationService` entries, but loaded at runtime from `?customization=`. Any
customization key that is read through `customizationService.getCustomization(...)` can therefore be
set this way. The examples below are complete files; drop one under `platform/app/public/customizations/`
(the `default` prefix) and load it with `?customization=<fileName>`. Because the files are JSONC, you
can keep `//` comments and trailing commas in them.
(the `default` prefix) and load it with `?customization=<path>`. The shipped examples are grouped into
subfolders by area (e.g. `tools/`, `worklist/`, `segmentation/`, `veterinary/`), and a nested path is
just part of the name under the `default` prefix — `?customization=tools/ctPresets` resolves to
`./customizations/tools/ctPresets.jsonc`. Because the files are JSONC, you can keep `//` comments
and trailing commas in them.
The shipped [`veterinaryOverlay.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/veterinaryOverlay.jsonc)
The shipped [`veterinary/veterinaryOverlay.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/veterinary/veterinaryOverlay.jsonc)
demonstrates a fourth scenario — replacing the viewport overlay layout via `viewportOverlay.topLeft` /
`viewportOverlay.topRight`.
@ -120,7 +123,7 @@ Override the CT presets offered in the window-level menu (key: `cornerstone.wind
`$merge` replaces only the `CT` entry, so presets for other modalities (PT, etc.) are kept.
```jsonc
// platform/app/public/customizations/ctPresets.jsonc -> ?customization=ctPresets
// platform/app/public/customizations/tools/ctPresets.jsonc -> ?customization=tools/ctPresets
{
"global": {
"cornerstone.windowLevelPresets": {
@ -143,7 +146,7 @@ Make the viewer prompt for a label from a fixed list whenever a measurement is c
(key: `measurementLabels`).
```jsonc
// platform/app/public/customizations/measurementLabels.jsonc -> ?customization=measurementLabels
// platform/app/public/customizations/tools/measurementLabels.jsonc -> ?customization=tools/measurementLabels
{
"global": {
"measurementLabels": {
@ -169,12 +172,12 @@ The basic and longitudinal viewers register their toolbar as customizations
layout that maps each section to a list of button ids). A module can therefore add a button by
`$push`-ing a definition onto `cornerstone.toolbarButtons` and the button's id onto a section.
The shipped [`smoothRotate.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/smoothRotate.jsonc)
The shipped [`tools/smoothRotate.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/tools/smoothRotate.jsonc)
adds a **Smooth Rotate** button to the *More Tools* menu that activates the cornerstone `PlanarRotate`
tool (drag to rotate the image freely, unlike the fixed 90° *Rotate Right*):
```jsonc
// platform/app/public/customizations/smoothRotate.jsonc -> ?customization=smoothRotate
// platform/app/public/customizations/tools/smoothRotate.jsonc -> ?customization=tools/smoothRotate
{
"global": {
"cornerstone.toolbarButtons": {
@ -207,10 +210,97 @@ tool (drag to rotate the image freely, unlike the fixed 90° *Rotate Right*):
> module applies at the *global* scope, the `$push` **extends** the built-in buttons rather than
> replacing them. The same pattern works for any tool already in the active tool group.
#### 4. Compose whole capability blocks into a mode
Ownership is split into three layers. **Extensions** export reusable *capability packs* — button
definitions, section layouts and tool lists — under their own namespace (`cornerstone.*`,
`tmtv.*`); the packs carry no mode identity. **Modes** own *composition*: each mode declares which
packs it uses as plain arrays on its instance (`toolbarButtons`, `toolbarSections`,
`toolGroupAdditions`), naming each pack with a `{ $reference: '<name>' }` marker, and the mode route
seeds those onto the **Mode** customization scope on enter — exactly like it seeds
`leftPanels` / `rightPanels` from the layout. **Config** (`?customization=`) re-composes an
existing mode through the `mode` phase.
Because composition is per-mode, a JSON module targets a mode with a `mode` phase block keyed by the
mode's id or route name and refines the plain concept keys — pushing a `{ $reference }` to a pack
instead of restating its contents:
```jsonc
{
"mode": {
"basic": {
"toolbarButtons": { "$push": [{ "$reference": "cornerstone.segmentationToolbarButtons" }] },
"toolGroupAdditions": {
"default": { "$push": [{ "$reference": "cornerstone.segmentationTools" }] }
}
}
}
}
```
There are no `basic.*` / `segmentation.*` / `tmtv.*` keys — `mode.basic` / `mode.viewer` /
`mode.segmentation` already select the mode, and the key is the concept (`toolbarButtons`), the same
way `rightPanels` works for the sidebars. Reserve the `global` phase for values that truly apply
to every mode.
**`$reference` — composing customizations by name.** A `{ "$reference": "<name>" }` object resolves,
when the value is *read*, to the value of the customization `<name>`. References may sit anywhere in a
value; inside an array a reference to another array is *flattened* in, so a list composes several
packs by name. Because resolution is at read time (not when customizations merge), a later command
composes naturally: `$push` adds another `{ $reference }`, and `$set` replaces the whole value —
with a different `{ $reference }` **or** a hard-coded list:
```jsonc
{ "mode": { "basic": {
// swap the entire toolbar for a different pack …
"toolbarButtons": { "$set": [{ "$reference": "myExtension.myToolbarButtons" }] }
// … or for a hard-coded list of button definitions
// "toolbarButtons": { "$set": [ { "id": "Length", /* … */ } ] }
} } }
```
Edits to the referenced pack itself are picked up live, and reference cycles are detected and warned.
Capability packs exported by the cornerstone extension:
- `cornerstone.toolbarButtons` / `cornerstone.toolbarSections` — the general viewer toolbar.
- `cornerstone.segmentationToolbarButtons` / `cornerstone.segmentationToolbarSections` — the
segmentation editing buttons and the toolbox section wiring rendered by the
`panelSegmentationWithTools*` panels.
- `cornerstone.segmentationModeToolbarSections` — a reusable segmentation-mode main toolbar layout.
- `cornerstone.segmentationTools` — the segmentation editing tools (brushes, scissors,
contour tools) as a `{ passive: [...] }` block for `toolGroupAdditions`.
- `cornerstone.annotationTools` — the measurement/annotation tools as a
`{ passive: [...] }` block for `toolGroupAdditions`.
The tmtv extension exports its TMTV-specific `tmtv.toolbarButtons` / `tmtv.toolbarSections` packs the
same way.
Two shipped modules demonstrate the pattern:
- [`segmentation/segmentationEditing.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/segmentation/segmentationEditing.jsonc)
(`?customization=segmentation/segmentationEditing`) adds segmentation editing to the basic and longitudinal
modes: in `mode` phase blocks keyed by each mode's route name (`basic`, `viewer`) it `$push`es the
segmentation button/section/tool packs onto that mode's `toolbarButtons` / `toolbarSections` /
`toolGroupAdditions`, swaps the right panels via `rightPanels`, and enables editing via
`panelSegmentation.disableEditing`.
- [`segmentation/segmentationAnnotationTools.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/segmentation/segmentationAnnotationTools.jsonc)
(`?customization=segmentation/segmentationAnnotationTools`) enables the annotation tools inside the
segmentation mode: in the `mode.segmentation` block it adds a `MeasurementTools` section to the
primary bar, `$push`es a `{ $reference }` to `cornerstone.annotationTools` onto
`toolGroupAdditions`, and `$push`es the measurement panel onto `rightPanels`.
Each payload value uses [immutability-helper](https://github.com/kolodny/immutability-helper)
commands (`$set`, `$push`, `$merge`, ...) exactly like `window.config` customizations, so a module can
also append to a list or merge into an existing object rather than replacing it wholesale.
> **Every mode's panels are customizable with no opt-in.** A mode's layout declares
> `leftPanels` / `rightPanels` as ordinary **arrays of panel ids** — the standard setup. On mode
> enter the mode route seeds those arrays into the `leftPanels` / `rightPanels`
> customizations at the bottom of the mode scope, then applies the `mode` phase blocks, then
> resolves the sidebars from the final values — so commands compose with the mode's own list and
> global-scope values win by scope precedence.
### URL modules, bootstrap, and client-side navigation (intended behavior)
Modules referenced from `?customization=` are loaded when the app applies URL customizations from

9
pnpm-lock.yaml generated
View File

@ -1030,6 +1030,9 @@ importers:
'@ohif/extension-test':
specifier: workspace:*
version: link:../../extensions/test-extension
'@ohif/mode-basic':
specifier: workspace:*
version: link:../basic
i18next:
specifier: 17.3.1
version: 17.3.1
@ -1254,6 +1257,12 @@ importers:
'@ohif/extension-measurement-tracking':
specifier: workspace:*
version: link:../../extensions/measurement-tracking
'@ohif/extension-tmtv':
specifier: workspace:*
version: link:../../extensions/tmtv
'@ohif/mode-basic':
specifier: workspace:*
version: link:../basic
i18next:
specifier: 17.3.1
version: 17.3.1

View File

@ -4,7 +4,7 @@ test('should apply customization from URL query parameter', async ({ page }) =>
const studyInstanceUID = '2.25.96975534054447904995905761963464388233';
await visitStudyOptions(page, studyInstanceUID, {
customization: 'veterinaryOverlay',
customization: 'veterinary/veterinaryOverlay',
});
const patientNameOverlayItem = page