From b266c0a86aee64d181a08c1ee65437650234b847 Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Fri, 10 Jul 2026 12:43:17 -0400 Subject: [PATCH] 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 --- README.md | 27 + .../segmentationToolbarCustomization.ts | 486 +++++------------- .../toolGroupToolsCustomization.ts | 204 ++++++++ .../toolbarButtonsCustomization.ts | 14 +- .../src/getCustomizationModule.tsx | 4 + .../customizations/toolbarCustomization.ts | 69 ++- extensions/tmtv/src/getCustomizationModule.ts | 17 + extensions/tmtv/src/index.tsx | 4 + extensions/tmtv/src/toolGroupIds.ts | 14 + modes/basic-test-mode/package.json | 1 + modes/basic-test-mode/src/index.ts | 373 ++++++-------- modes/basic-test-mode/src/initToolGroups.ts | 2 +- modes/basic/src/index.tsx | 243 ++++++--- modes/basic/src/initToolGroups.ts | 12 +- modes/basic/src/modeCustomization.ts | 101 ++++ modes/longitudinal/src/index.ts | 49 +- modes/segmentation/src/index.tsx | 361 +++++-------- modes/segmentation/src/initToolGroups.ts | 2 +- modes/tmtv/package.json | 4 +- modes/tmtv/src/index.ts | 400 +++++++------- modes/tmtv/src/initToolGroups.js | 21 +- platform/app/public/config/customization.js | 6 +- platform/app/public/config/default.js | 2 +- platform/app/public/config/dev.js | 15 +- platform/app/public/config/e2e.js | 2 +- platform/app/public/config/netlify.js | 2 +- platform/app/public/customizations/index.html | 400 ++++++++++++++ .../segmentationAnnotationTools.jsonc | 48 ++ .../segmentation/segmentationEditing.jsonc | 59 +++ .../{ => tools}/ctPresets.jsonc | 2 +- .../{ => tools}/measurementLabels.jsonc | 2 +- .../{ => tools}/smoothRotate.jsonc | 2 +- .../public/customizations/veterinary.jsonc | 5 - .../veterinary/veterinary.jsonc | 5 + .../{ => veterinary}/veterinaryOverlay.jsonc | 6 +- .../{ => worklist}/patientBirthDate.jsonc | 4 +- platform/app/src/appInit.js | 34 +- platform/app/src/routes/Mode/Mode.tsx | 68 ++- .../src/utils/preserveQueryParameters.test.ts | 8 + .../app/src/utils/preserveQueryParameters.ts | 1 + .../CustomizationService.reference.test.ts | 180 +++++++ .../CustomizationService.ts | 102 +++- .../3p12-to-3p13/customization-url.md | 26 +- .../3p12-to-3p13/mode-extensibility.md | 179 +++++++ .../3p12-to-3p13/mode-panels.md | 78 +++ platform/docs/docs/platform/modes/index.md | 109 +++- .../specificCustomizations.md | 104 +++- pnpm-lock.yaml | 9 + tests/Customization.spec.ts | 2 +- 49 files changed, 2653 insertions(+), 1215 deletions(-) rename modes/segmentation/src/toolbarButtons.ts => extensions/cornerstone/src/customizations/segmentationToolbarCustomization.ts (72%) create mode 100644 extensions/cornerstone/src/customizations/toolGroupToolsCustomization.ts rename modes/tmtv/src/toolbarButtons.ts => extensions/tmtv/src/customizations/toolbarCustomization.ts (85%) create mode 100644 extensions/tmtv/src/getCustomizationModule.ts create mode 100644 extensions/tmtv/src/toolGroupIds.ts create mode 100644 modes/basic/src/modeCustomization.ts create mode 100644 platform/app/public/customizations/index.html create mode 100644 platform/app/public/customizations/segmentation/segmentationAnnotationTools.jsonc create mode 100644 platform/app/public/customizations/segmentation/segmentationEditing.jsonc rename platform/app/public/customizations/{ => tools}/ctPresets.jsonc (93%) rename platform/app/public/customizations/{ => tools}/measurementLabels.jsonc (91%) rename platform/app/public/customizations/{ => tools}/smoothRotate.jsonc (96%) delete mode 100644 platform/app/public/customizations/veterinary.jsonc create mode 100644 platform/app/public/customizations/veterinary/veterinary.jsonc rename platform/app/public/customizations/{ => veterinary}/veterinaryOverlay.jsonc (88%) rename platform/app/public/customizations/{ => worklist}/patientBirthDate.jsonc (88%) create mode 100644 platform/core/src/services/CustomizationService/CustomizationService.reference.test.ts create mode 100644 platform/docs/docs/migration-guide/3p12-to-3p13/mode-extensibility.md create mode 100644 platform/docs/docs/migration-guide/3p12-to-3p13/mode-panels.md diff --git a/README.md b/README.md index a6d08f4bd..005fa5fbe 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/modes/segmentation/src/toolbarButtons.ts b/extensions/cornerstone/src/customizations/segmentationToolbarCustomization.ts similarity index 72% rename from modes/segmentation/src/toolbarButtons.ts rename to extensions/cornerstone/src/customizations/segmentationToolbarCustomization.ts index ecf2a5b36..84861355f 100644 --- a/modes/segmentation/src/toolbarButtons.ts +++ b/extensions/cornerstone/src/customizations/segmentationToolbarCustomization.ts @@ -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 = { + [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 = { + [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; diff --git a/extensions/cornerstone/src/customizations/toolGroupToolsCustomization.ts b/extensions/cornerstone/src/customizations/toolGroupToolsCustomization.ts new file mode 100644 index 000000000..d0f5c4d87 --- /dev/null +++ b/extensions/cornerstone/src/customizations/toolGroupToolsCustomization.ts @@ -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; diff --git a/extensions/cornerstone/src/customizations/toolbarButtonsCustomization.ts b/extensions/cornerstone/src/customizations/toolbarButtonsCustomization.ts index c134cd363..699792184 100644 --- a/extensions/cornerstone/src/customizations/toolbarButtonsCustomization.ts +++ b/extensions/cornerstone/src/customizations/toolbarButtonsCustomization.ts @@ -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, diff --git a/extensions/cornerstone/src/getCustomizationModule.tsx b/extensions/cornerstone/src/getCustomizationModule.tsx index 206afc5ab..ddb4e8dfe 100644 --- a/extensions/cornerstone/src/getCustomizationModule.tsx +++ b/extensions/cornerstone/src/getCustomizationModule.tsx @@ -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, diff --git a/modes/tmtv/src/toolbarButtons.ts b/extensions/tmtv/src/customizations/toolbarCustomization.ts similarity index 85% rename from modes/tmtv/src/toolbarButtons.ts rename to extensions/tmtv/src/customizations/toolbarCustomization.ts index c47357c56..61cdd2848 100644 --- a/modes/tmtv/src/toolbarButtons.ts +++ b/extensions/tmtv/src/customizations/toolbarCustomization.ts @@ -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 = { + [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; diff --git a/extensions/tmtv/src/getCustomizationModule.ts b/extensions/tmtv/src/getCustomizationModule.ts new file mode 100644 index 000000000..5a4ecdebe --- /dev/null +++ b/extensions/tmtv/src/getCustomizationModule.ts @@ -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, + }, + }, + ]; +} diff --git a/extensions/tmtv/src/index.tsx b/extensions/tmtv/src/index.tsx index 9d853c996..b9e034815 100644 --- a/extensions/tmtv/src/index.tsx +++ b/extensions/tmtv/src/index.tsx @@ -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 }; diff --git a/extensions/tmtv/src/toolGroupIds.ts b/extensions/tmtv/src/toolGroupIds.ts new file mode 100644 index 000000000..d741d04f6 --- /dev/null +++ b/extensions/tmtv/src/toolGroupIds.ts @@ -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; diff --git a/modes/basic-test-mode/package.json b/modes/basic-test-mode/package.json index 215ce170b..dbdb6300f 100644 --- a/modes/basic-test-mode/package.json +++ b/modes/basic-test-mode/package.json @@ -40,6 +40,7 @@ }, "dependencies": { "@babel/runtime": "7.29.7", + "@ohif/mode-basic": "workspace:*", "i18next": "17.3.1" }, "devDependencies": { diff --git a/modes/basic-test-mode/src/index.ts b/modes/basic-test-mode/src/index.ts index 540b0fff9..40c698ff5 100644 --- a/modes/basic-test-mode/src/index.ts +++ b/modes/basic-test-mode/src/index.ts @@ -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 }; diff --git a/modes/basic-test-mode/src/initToolGroups.ts b/modes/basic-test-mode/src/initToolGroups.ts index ba5ec9d5a..1ef2a6d4e 100644 --- a/modes/basic-test-mode/src/initToolGroups.ts +++ b/modes/basic-test-mode/src/initToolGroups.ts @@ -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); diff --git a/modes/basic/src/index.tsx b/modes/basic/src/index.tsx index 223d1d1a0..46bb3a10c 100644 --- a/modes/basic/src/index.tsx +++ b/modes/basic/src/index.tsx @@ -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'; diff --git a/modes/basic/src/initToolGroups.ts b/modes/basic/src/initToolGroups.ts index 7032bad6a..1b9b77668 100644 --- a/modes/basic/src/initToolGroups.ts +++ b/modes/basic/src/initToolGroups.ts @@ -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); diff --git a/modes/basic/src/modeCustomization.ts b/modes/basic/src/modeCustomization.ts new file mode 100644 index 000000000..b5b5712f2 --- /dev/null +++ b/modes/basic/src/modeCustomization.ts @@ -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: '' }` 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 = 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; +} diff --git a/modes/longitudinal/src/index.ts b/modes/longitudinal/src/index.ts index 792ac3a01..f087907ec 100644 --- a/modes/longitudinal/src/index.ts +++ b/modes/longitudinal/src/index.ts @@ -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, diff --git a/modes/segmentation/src/index.tsx b/modes/segmentation/src/index.tsx index 51f08e2d7..d95b679e8 100644 --- a/modes/segmentation/src/index.tsx +++ b/modes/segmentation/src/index.tsx @@ -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 }; diff --git a/modes/segmentation/src/initToolGroups.ts b/modes/segmentation/src/initToolGroups.ts index 23509a8c9..6896e37b2 100644 --- a/modes/segmentation/src/initToolGroups.ts +++ b/modes/segmentation/src/initToolGroups.ts @@ -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); diff --git a/modes/tmtv/package.json b/modes/tmtv/package.json index 217297f33..f80e27b0f 100644 --- a/modes/tmtv/package.json +++ b/modes/tmtv/package.json @@ -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", diff --git a/modes/tmtv/src/index.ts b/modes/tmtv/src/index.ts index 33f74a798..b6a2e696f 100644 --- a/modes/tmtv/src/index.ts +++ b/modes/tmtv/src/index.ts @@ -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 }; diff --git a/modes/tmtv/src/initToolGroups.js b/modes/tmtv/src/initToolGroups.js index f88b1cf84..ec5daacf2 100644 --- a/modes/tmtv/src/initToolGroups.js +++ b/modes/tmtv/src/initToolGroups.js @@ -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); } diff --git a/platform/app/public/config/customization.js b/platform/app/public/config/customization.js index d05491e81..6f51824bd 100644 --- a/platform/app/public/config/customization.js +++ b/platform/app/public/config/customization.js @@ -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 diff --git a/platform/app/public/config/default.js b/platform/app/public/config/default.js index bd72b2e37..d8e1a64b2 100644 --- a/platform/app/public/config/default.js +++ b/platform/app/public/config/default.js @@ -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 // }, // ---------------------------------------------------------------------------- diff --git a/platform/app/public/config/dev.js b/platform/app/public/config/dev.js index 49987d825..d1ae75503 100644 --- a/platform/app/public/config/dev.js +++ b/platform/app/public/config/dev.js @@ -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/', }, diff --git a/platform/app/public/config/e2e.js b/platform/app/public/config/e2e.js index 9093acf48..7bd5a1628 100644 --- a/platform/app/public/config/e2e.js +++ b/platform/app/public/config/e2e.js @@ -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/', }, diff --git a/platform/app/public/config/netlify.js b/platform/app/public/config/netlify.js index 17631a4c2..b1309af4c 100644 --- a/platform/app/public/config/netlify.js +++ b/platform/app/public/config/netlify.js @@ -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/', }, diff --git a/platform/app/public/customizations/index.html b/platform/app/public/customizations/index.html new file mode 100644 index 000000000..2d9842c5b --- /dev/null +++ b/platform/app/public/customizations/index.html @@ -0,0 +1,400 @@ + + + + + + OHIF URL Customizations + + + +
+
+
+
+

URL Customizations

+
+
+ + +
+
+

+ These are the example ?customization= profiles shipped under + platform/app/public/customizations/, grouped by area. Each file is fetched as + JSONC data (never executed) and applied at startup. Tick the ones you want, + optionally pick a viewer theme (added as ?theme=), then launch the viewer or copy + the generated query string onto any viewer URL. Multiple profiles are comma-separated and + applied in order. +

+

+ See the + + customization docs + for how to author your own. The prefix allowlist is set by + customizationUrlPrefixes in the app config. +

+
+ +
+
+ +
+
+
+ Viewer base + + 0 selected + + + Launch viewer → +
+
+
Select one or more profiles above…
+
+
+
+ + + + diff --git a/platform/app/public/customizations/segmentation/segmentationAnnotationTools.jsonc b/platform/app/public/customizations/segmentation/segmentationAnnotationTools.jsonc new file mode 100644 index 000000000..75b04a379 --- /dev/null +++ b/platform/app/public/customizations/segmentation/segmentationAnnotationTools.jsonc @@ -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"] + } + } + } +} diff --git a/platform/app/public/customizations/segmentation/segmentationEditing.jsonc b/platform/app/public/customizations/segmentation/segmentationEditing.jsonc new file mode 100644 index 000000000..165051bfa --- /dev/null +++ b/platform/app/public/customizations/segmentation/segmentationEditing.jsonc @@ -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" + ] + } + } + } +} diff --git a/platform/app/public/customizations/ctPresets.jsonc b/platform/app/public/customizations/tools/ctPresets.jsonc similarity index 93% rename from platform/app/public/customizations/ctPresets.jsonc rename to platform/app/public/customizations/tools/ctPresets.jsonc index 01311dfdd..2b83277de 100644 --- a/platform/app/public/customizations/ctPresets.jsonc +++ b/platform/app/public/customizations/tools/ctPresets.jsonc @@ -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": { diff --git a/platform/app/public/customizations/measurementLabels.jsonc b/platform/app/public/customizations/tools/measurementLabels.jsonc similarity index 91% rename from platform/app/public/customizations/measurementLabels.jsonc rename to platform/app/public/customizations/tools/measurementLabels.jsonc index 6a5b12d32..6c2dd5984 100644 --- a/platform/app/public/customizations/measurementLabels.jsonc +++ b/platform/app/public/customizations/tools/measurementLabels.jsonc @@ -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": { diff --git a/platform/app/public/customizations/smoothRotate.jsonc b/platform/app/public/customizations/tools/smoothRotate.jsonc similarity index 96% rename from platform/app/public/customizations/smoothRotate.jsonc rename to platform/app/public/customizations/tools/smoothRotate.jsonc index c93072a54..9269c022a 100644 --- a/platform/app/public/customizations/smoothRotate.jsonc +++ b/platform/app/public/customizations/tools/smoothRotate.jsonc @@ -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": { diff --git a/platform/app/public/customizations/veterinary.jsonc b/platform/app/public/customizations/veterinary.jsonc deleted file mode 100644 index f64181818..000000000 --- a/platform/app/public/customizations/veterinary.jsonc +++ /dev/null @@ -1,5 +0,0 @@ -// Example chaining module: ensures `veterinaryOverlay` is loaded and applied -// first when using `?customization=veterinary` alone. -{ - "requires": ["veterinaryOverlay"] -} diff --git a/platform/app/public/customizations/veterinary/veterinary.jsonc b/platform/app/public/customizations/veterinary/veterinary.jsonc new file mode 100644 index 000000000..b228538d3 --- /dev/null +++ b/platform/app/public/customizations/veterinary/veterinary.jsonc @@ -0,0 +1,5 @@ +// Example chaining module: ensures `veterinary/veterinaryOverlay` is loaded and +// applied first when using `?customization=veterinary/veterinary` alone. +{ + "requires": ["veterinary/veterinaryOverlay"] +} diff --git a/platform/app/public/customizations/veterinaryOverlay.jsonc b/platform/app/public/customizations/veterinary/veterinaryOverlay.jsonc similarity index 88% rename from platform/app/public/customizations/veterinaryOverlay.jsonc rename to platform/app/public/customizations/veterinary/veterinaryOverlay.jsonc index 57dff05f2..eecfeb925 100644 --- a/platform/app/public/customizations/veterinaryOverlay.jsonc +++ b/platform/app/public/customizations/veterinary/veterinaryOverlay.jsonc @@ -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" } diff --git a/platform/app/public/customizations/patientBirthDate.jsonc b/platform/app/public/customizations/worklist/patientBirthDate.jsonc similarity index 88% rename from platform/app/public/customizations/patientBirthDate.jsonc rename to platform/app/public/customizations/worklist/patientBirthDate.jsonc index 36e9fbd67..4a65c4cf3 100644 --- a/platform/app/public/customizations/patientBirthDate.jsonc +++ b/platform/app/public/customizations/worklist/patientBirthDate.jsonc @@ -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": { diff --git a/platform/app/src/appInit.js b/platform/app/src/appInit.js index 9059db6fa..75ad9cc00 100644 --- a/platform/app/src/appInit.js +++ b/platform/app/src/appInit.js @@ -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(); diff --git a/platform/app/src/routes/Mode/Mode.tsx b/platform/app/src/routes/Mode/Mode.tsx index 2d87e24a4..fcdef7cd6 100644 --- a/platform/app/src/routes/Mode/Mode.tsx +++ b/platform/app/src/routes/Mode/Mode.tsx @@ -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( diff --git a/platform/app/src/utils/preserveQueryParameters.test.ts b/platform/app/src/utils/preserveQueryParameters.test.ts index d8db8937f..67a67c547 100644 --- a/platform/app/src/utils/preserveQueryParameters.test.ts +++ b/platform/app/src/utils/preserveQueryParameters.test.ts @@ -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'); diff --git a/platform/app/src/utils/preserveQueryParameters.ts b/platform/app/src/utils/preserveQueryParameters.ts index 195c28e24..47e006a94 100644 --- a/platform/app/src/utils/preserveQueryParameters.ts +++ b/platform/app/src/utils/preserveQueryParameters.ts @@ -12,6 +12,7 @@ export const preserveKeys = [ 'screenNumber', 'hangingProtocolId', 'customization', + 'theme', ]; function preserveKey(query: URLSearchParams, current: URLSearchParams, key: string) { diff --git a/platform/core/src/services/CustomizationService/CustomizationService.reference.test.ts b/platform/core/src/services/CustomizationService/CustomizationService.reference.test.ts new file mode 100644 index 000000000..9359a5369 --- /dev/null +++ b/platform/core/src/services/CustomizationService/CustomizationService.reference.test.ts @@ -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(); + }); +}); diff --git a/platform/core/src/services/CustomizationService/CustomizationService.ts b/platform/core/src/services/CustomizationService/CustomizationService.ts index c7173b30d..eb379cfbf 100644 --- a/platform/core/src/services/CustomizationService/CustomizationService.ts +++ b/platform/core/src/services/CustomizationService/CustomizationService.ts @@ -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: '' }` object is replaced by the value of the + * customization `` (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): 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 = {}; + 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): 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])) { diff --git a/platform/docs/docs/migration-guide/3p12-to-3p13/customization-url.md b/platform/docs/docs/migration-guide/3p12-to-3p13/customization-url.md index 4fe02b69e..91ac7231b 100644 --- a/platform/docs/docs/migration-guide/3p12-to-3p13/customization-url.md +++ b/platform/docs/docs/migration-guide/3p12-to-3p13/customization-url.md @@ -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 diff --git a/platform/docs/docs/migration-guide/3p12-to-3p13/mode-extensibility.md b/platform/docs/docs/migration-guide/3p12-to-3p13/mode-extensibility.md new file mode 100644 index 000000000..0e6f91a69 --- /dev/null +++ b/platform/docs/docs/migration-guide/3p12-to-3p13/mode-extensibility.md @@ -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. diff --git a/platform/docs/docs/migration-guide/3p12-to-3p13/mode-panels.md b/platform/docs/docs/migration-guide/3p12-to-3p13/mode-panels.md new file mode 100644 index 000000000..fdb48fdfd --- /dev/null +++ b/platform/docs/docs/migration-guide/3p12-to-3p13/mode-panels.md @@ -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). diff --git a/platform/docs/docs/platform/modes/index.md b/platform/docs/docs/platform/modes/index.md index 2d6fc98ba..b1a5a1fa1 100644 --- a/platform/docs/docs/platform/modes/index.md +++ b/platform/docs/docs/platform/modes/index.md @@ -225,15 +225,51 @@ export default mode; - enableSegmentationEdit + excludedModalities - Boolean to skip the segmentation edit capabilities + The default isValidMode returns false when the modalities list contains ANY of these + + + + excludedStudies + + A list of study attribute objects; the default isValidMode returns false for a study matching every attribute of any entry, e.g. [{ mrn: 'M1' }] toolbarSections - An object containing toolbar section definitions to register + Toolbar section composition: a list of section-layout packs (as $reference markers) and/or literal section objects; seeded onto the Mode scope on enter + + + + toolbarButtons + + Toolbar button composition: a list of button packs (as $reference markers) and/or literal button definitions; seeded onto the Mode scope on enter + + + + toolGroupAdditions + + Per-tool-group composition: a map of tool-group id to a list of tool packs (as $reference markers) and/or literal tool blocks, layered onto the mode's tool groups after creation + + + + initToolGroups + + Tool group setup function called by the shared onModeEnter as initToolGroups({ extensionManager, toolGroupService, commandsManager, servicesManager }); extending modes can substitute their own + + + + modeCustomizations + + 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 mode 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. basicModeCustomizations, which sets panelSegmentation.disableEditing); may also be a literal object of immutability-helper commands or an array mixing those with customization module reference strings + + + + activatePanelTriggers + + Data-driven ActivatePanel event triggers: a list of { panelId, sourceServiceName, sourceEvents, forceActive? } entries wired up on mode enter (e.g. activating the segmentation panel when a segmentation is added). Empty by default; see defaultActivatePanelTriggers in the basic mode @@ -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` diff --git a/platform/docs/docs/platform/services/customization-service/specificCustomizations.md b/platform/docs/docs/platform/services/customization-service/specificCustomizations.md index 848c3b6e7..f096ba98d 100644 --- a/platform/docs/docs/platform/services/customization-service/specificCustomizations.md +++ b/platform/docs/docs/platform/services/customization-service/specificCustomizations.md @@ -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=`. Because the files are JSONC, you -can keep `//` comments and trailing commas in them. +(the `default` prefix) and load it with `?customization=`. 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: '' }` 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": "" }` object resolves, +when the value is *read*, to the value of the customization ``. 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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19576a32d..df2432953 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/tests/Customization.spec.ts b/tests/Customization.spec.ts index c9c8c4994..f3abb0966 100644 --- a/tests/Customization.spec.ts +++ b/tests/Customization.spec.ts @@ -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