feat: Add extensibility for tmtv and segmentation modes (#6128)
* feat: Add extensibility for tmtv and segmentation modes * Fixes for ordering issues on laod * Remove unnecessary reference lookup * Chane side panel timing to fix tests * PR comments - change how mode definitions get created * Improvements to mode customizations * Start organizing customizations * Misc fixes for a customization demo page * Security fixes * PR requested changes to naming
This commit is contained in:
parent
f79055f98e
commit
b266c0a86a
27
README.md
27
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
|
||||
|
||||
@ -1,8 +1,11 @@
|
||||
import type { Button } from '@ohif/core/types';
|
||||
import { ViewportGridService } from '@ohif/core';
|
||||
import { ViewportGridService, ToolbarService } from '@ohif/core';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { MIN_SEGMENTATION_DRAWING_RADIUS, MAX_SEGMENTATION_DRAWING_RADIUS } from './constants';
|
||||
const { TOOLBAR_SECTIONS } = ToolbarService;
|
||||
|
||||
export const MIN_SEGMENTATION_DRAWING_RADIUS = 0.5;
|
||||
export const MAX_SEGMENTATION_DRAWING_RADIUS = 99.5;
|
||||
|
||||
const setToolActiveToolbar = {
|
||||
commandName: 'setToolActiveToolbar',
|
||||
@ -20,155 +23,16 @@ const callbacks = (toolName: string) => [
|
||||
},
|
||||
];
|
||||
|
||||
export const toolbarButtons: Button[] = [
|
||||
{
|
||||
id: 'AdvancedRenderingControls',
|
||||
uiType: 'ohif.advancedRenderingControls',
|
||||
props: {
|
||||
buttonSection: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'modalityLoadBadge',
|
||||
uiType: 'ohif.modalityLoadBadge',
|
||||
props: {
|
||||
icon: 'Status',
|
||||
label: i18n.t('Buttons:Status'),
|
||||
tooltip: i18n.t('Buttons:Status'),
|
||||
evaluate: {
|
||||
name: 'evaluate.modalityLoadBadge',
|
||||
hideWhenDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'navigationComponent',
|
||||
uiType: 'ohif.navigationComponent',
|
||||
props: {
|
||||
icon: 'Navigation',
|
||||
label: i18n.t('Buttons:Navigation'),
|
||||
tooltip: i18n.t('Buttons:Navigate between segments/measurements and manage their visibility'),
|
||||
evaluate: {
|
||||
name: 'evaluate.navigationComponent',
|
||||
hideWhenDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'trackingStatus',
|
||||
uiType: 'ohif.trackingStatus',
|
||||
props: {
|
||||
icon: 'TrackingStatus',
|
||||
label: i18n.t('Buttons:Tracking Status'),
|
||||
tooltip: i18n.t('Buttons:View and manage tracking status of measurements and annotations'),
|
||||
evaluate: {
|
||||
name: 'evaluate.trackingStatus',
|
||||
hideWhenDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dataOverlayMenu',
|
||||
uiType: 'ohif.dataOverlayMenu',
|
||||
props: {
|
||||
icon: 'ViewportViews',
|
||||
label: i18n.t('Buttons:Data Overlay'),
|
||||
tooltip: i18n.t(
|
||||
'Buttons:Configure data overlay options and manage foreground/background display sets'
|
||||
),
|
||||
evaluate: 'evaluate.dataOverlayMenu',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'orientationMenu',
|
||||
uiType: 'ohif.orientationMenu',
|
||||
props: {
|
||||
icon: 'OrientationSwitch',
|
||||
label: i18n.t('Buttons:Orientation'),
|
||||
tooltip: i18n.t(
|
||||
'Buttons:Change viewport orientation between axial, sagittal, coronal and reformat planes'
|
||||
),
|
||||
evaluate: {
|
||||
name: 'evaluate.orientationMenu',
|
||||
// hideWhenDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'windowLevelMenuEmbedded',
|
||||
uiType: 'ohif.windowLevelMenuEmbedded',
|
||||
props: {
|
||||
icon: 'WindowLevel',
|
||||
label: i18n.t('Buttons:Window Level'),
|
||||
tooltip: i18n.t('Buttons:Adjust window/level presets and customize image contrast settings'),
|
||||
evaluate: {
|
||||
name: 'evaluate.windowLevelMenuEmbedded',
|
||||
hideWhenDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'windowLevelMenu',
|
||||
uiType: 'ohif.windowLevelMenu',
|
||||
props: {
|
||||
icon: 'WindowLevel',
|
||||
label: i18n.t('Buttons:Window Level'),
|
||||
tooltip: i18n.t('Buttons:Adjust window/level presets and customize image contrast settings'),
|
||||
evaluate: 'evaluate.windowLevelMenu',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'voiManualControlMenu',
|
||||
uiType: 'ohif.voiManualControlMenu',
|
||||
props: {
|
||||
icon: 'WindowLevelAdvanced',
|
||||
label: i18n.t('Buttons:Advanced Window Level'),
|
||||
tooltip: i18n.t('Buttons:Advanced window/level settings with manual controls and presets'),
|
||||
evaluate: 'evaluate.voiManualControlMenu',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'thresholdMenu',
|
||||
uiType: 'ohif.thresholdMenu',
|
||||
props: {
|
||||
icon: 'Threshold',
|
||||
label: i18n.t('Buttons:Threshold'),
|
||||
tooltip: i18n.t('Buttons:Image threshold settings'),
|
||||
evaluate: {
|
||||
name: 'evaluate.thresholdMenu',
|
||||
hideWhenDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'opacityMenu',
|
||||
uiType: 'ohif.opacityMenu',
|
||||
props: {
|
||||
icon: 'Opacity',
|
||||
label: i18n.t('Buttons:Opacity'),
|
||||
tooltip: i18n.t('Buttons:Image opacity settings'),
|
||||
evaluate: {
|
||||
name: 'evaluate.opacityMenu',
|
||||
hideWhenDisabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Colorbar',
|
||||
uiType: 'ohif.colorbar',
|
||||
props: {
|
||||
type: 'tool',
|
||||
label: i18n.t('Buttons:Colorbar'),
|
||||
},
|
||||
},
|
||||
// sections
|
||||
{
|
||||
id: 'MoreTools',
|
||||
uiType: 'ohif.toolButtonList',
|
||||
props: {
|
||||
buttonSection: true,
|
||||
},
|
||||
},
|
||||
/**
|
||||
* Segmentation editing toolbar buttons: the toolbox section containers plus
|
||||
* the labelmap / contour editing tools and utilities. These complement the
|
||||
* general buttons in `cornerstone.toolbarButtons`; together they are the
|
||||
* default button set for the segmentation mode, and modes such as basic /
|
||||
* longitudinal can pull them in via a customization (see
|
||||
* `segmentationEditing.jsonc`).
|
||||
*/
|
||||
const segmentationToolbarButtons: Button[] = [
|
||||
// section containers for the nested toolboxes and toolbars
|
||||
{
|
||||
id: 'BrushTools',
|
||||
uiType: 'ohif.toolBoxButtonGroup',
|
||||
@ -176,7 +40,6 @@ export const toolbarButtons: Button[] = [
|
||||
buttonSection: true,
|
||||
},
|
||||
},
|
||||
// Section containers for the nested toolboxes and toolbars.
|
||||
{
|
||||
id: 'LabelMapUtilities',
|
||||
uiType: 'ohif.Toolbar',
|
||||
@ -206,215 +69,6 @@ export const toolbarButtons: Button[] = [
|
||||
},
|
||||
},
|
||||
// tool defs
|
||||
{
|
||||
id: 'Zoom',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'tool-zoom',
|
||||
label: i18n.t('Buttons:Zoom'),
|
||||
commands: setToolActiveToolbar,
|
||||
evaluate: 'evaluate.cornerstoneTool',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'WindowLevel',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'tool-window-level',
|
||||
label: i18n.t('Buttons:Window Level'),
|
||||
commands: setToolActiveToolbar,
|
||||
evaluate: 'evaluate.cornerstoneTool',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Pan',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'tool-move',
|
||||
label: i18n.t('Buttons:Pan'),
|
||||
commands: setToolActiveToolbar,
|
||||
evaluate: 'evaluate.cornerstoneTool',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'TrackballRotate',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
type: 'tool',
|
||||
icon: 'tool-3d-rotate',
|
||||
label: i18n.t('Buttons:3D Rotate'),
|
||||
commands: setToolActiveToolbar,
|
||||
evaluate: {
|
||||
name: 'evaluate.cornerstoneTool',
|
||||
disabledText: i18n.t('Buttons:Select a 3D viewport to enable this tool'),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Capture',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'tool-capture',
|
||||
label: i18n.t('Buttons:Capture'),
|
||||
commands: 'showDownloadViewportModal',
|
||||
evaluate: [
|
||||
'evaluate.action',
|
||||
{
|
||||
name: 'evaluate.viewport.supported',
|
||||
unsupportedViewportTypes: ['video', 'wholeSlide'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Layout',
|
||||
uiType: 'ohif.layoutSelector',
|
||||
props: {
|
||||
rows: 3,
|
||||
columns: 4,
|
||||
evaluate: 'evaluate.action',
|
||||
commands: 'setViewportGridLayout',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Crosshairs',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'tool-crosshair',
|
||||
label: i18n.t('Buttons:Crosshairs'),
|
||||
commands: {
|
||||
commandName: 'setToolActiveToolbar',
|
||||
commandOptions: {
|
||||
toolGroupIds: ['mpr'],
|
||||
},
|
||||
},
|
||||
evaluate: {
|
||||
name: 'evaluate.cornerstoneTool',
|
||||
disabledText: i18n.t('Buttons:Select an MPR viewport to enable this tool'),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Reset',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'tool-reset',
|
||||
label: i18n.t('Buttons:Reset View'),
|
||||
tooltip: i18n.t('Buttons:Reset View'),
|
||||
commands: 'resetViewport',
|
||||
evaluate: 'evaluate.action',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'rotate-right',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'tool-rotate-right',
|
||||
label: i18n.t('Buttons:Rotate Right'),
|
||||
tooltip: i18n.t('Buttons:Rotate +90'),
|
||||
commands: 'rotateViewportCW',
|
||||
evaluate: 'evaluate.action',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'flipHorizontal',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'tool-flip-horizontal',
|
||||
label: i18n.t('Buttons:Flip Horizontal'),
|
||||
tooltip: i18n.t('Buttons:Flip Horizontally'),
|
||||
commands: 'flipViewportHorizontal',
|
||||
evaluate: [
|
||||
'evaluate.viewportProperties.toggle',
|
||||
{
|
||||
name: 'evaluate.viewport.supported',
|
||||
unsupportedViewportTypes: ['volume3d'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ReferenceLines',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'tool-referenceLines',
|
||||
label: i18n.t('Buttons:Reference Lines'),
|
||||
tooltip: i18n.t('Buttons:Show Reference Lines'),
|
||||
commands: 'toggleEnabledDisabledToolbar',
|
||||
evaluate: 'evaluate.cornerstoneTool.toggle',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'ImageOverlayViewer',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'toggle-dicom-overlay',
|
||||
label: i18n.t('Buttons:Image Overlay'),
|
||||
tooltip: i18n.t('Buttons:Toggle Image Overlay'),
|
||||
commands: 'toggleEnabledDisabledToolbar',
|
||||
evaluate: 'evaluate.cornerstoneTool.toggle',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'StackScroll',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'tool-stack-scroll',
|
||||
label: i18n.t('Buttons:Stack Scroll'),
|
||||
tooltip: i18n.t('Buttons:Stack Scroll'),
|
||||
commands: setToolActiveToolbar,
|
||||
evaluate: 'evaluate.cornerstoneTool',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'invert',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'tool-invert',
|
||||
label: i18n.t('Buttons:Invert'),
|
||||
tooltip: i18n.t('Buttons:Invert Colors'),
|
||||
commands: 'invertViewport',
|
||||
evaluate: 'evaluate.viewportProperties.toggle',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Cine',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'tool-cine',
|
||||
label: i18n.t('Buttons:Cine'),
|
||||
tooltip: i18n.t('Buttons:Cine'),
|
||||
commands: 'toggleCine',
|
||||
evaluate: [
|
||||
'evaluate.cine',
|
||||
{
|
||||
name: 'evaluate.viewport.supported',
|
||||
unsupportedViewportTypes: ['volume3d'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'Magnify',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'tool-magnify',
|
||||
label: i18n.t('Buttons:Zoom-in'),
|
||||
tooltip: i18n.t('Buttons:Zoom-in'),
|
||||
commands: setToolActiveToolbar,
|
||||
evaluate: 'evaluate.cornerstoneTool',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'TagBrowser',
|
||||
uiType: 'ohif.toolButton',
|
||||
props: {
|
||||
icon: 'dicom-tag-browser',
|
||||
label: i18n.t('Buttons:Dicom Tag Browser'),
|
||||
tooltip: i18n.t('Buttons:Dicom Tag Browser'),
|
||||
commands: 'openDICOMTagViewer',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'PlanarFreehandContourSegmentationTool',
|
||||
uiType: 'ohif.toolBoxButton',
|
||||
@ -559,7 +213,11 @@ export const toolbarButtons: Button[] = [
|
||||
value: 'CatmullRomSplineROI',
|
||||
label: i18n.t('Buttons:Catmull Rom Spline'),
|
||||
},
|
||||
{ id: 'LinearSplineROI', value: 'LinearSplineROI', label: i18n.t('Buttons:Linear Spline') },
|
||||
{
|
||||
id: 'LinearSplineROI',
|
||||
value: 'LinearSplineROI',
|
||||
label: i18n.t('Buttons:Linear Spline'),
|
||||
},
|
||||
{ id: 'BSplineROI', value: 'BSplineROI', label: i18n.t('Buttons:B-Spline') },
|
||||
],
|
||||
commands: {
|
||||
@ -1195,4 +853,106 @@ export const toolbarButtons: Button[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export default toolbarButtons;
|
||||
/**
|
||||
* The toolbox / utilities section wiring for segmentation editing. These are
|
||||
* the sections rendered by the `panelSegmentationWithTools*` panels, so any
|
||||
* mode that shows those panels can merge this block into its toolbar sections.
|
||||
*/
|
||||
export const segmentationToolboxSections: Record<string, string[]> = {
|
||||
[TOOLBAR_SECTIONS.labelMapSegmentationToolbox]: ['LabelMapTools'],
|
||||
[TOOLBAR_SECTIONS.contourSegmentationToolbox]: ['ContourTools'],
|
||||
[TOOLBAR_SECTIONS.labelMapSegmentationUtilities]: ['LabelMapUtilities'],
|
||||
[TOOLBAR_SECTIONS.contourSegmentationUtilities]: ['ContourUtilities'],
|
||||
|
||||
LabelMapTools: [
|
||||
'LabelmapSlicePropagation',
|
||||
'BrushTools',
|
||||
'MarkerLabelmap',
|
||||
'RegionSegmentPlus',
|
||||
'Shapes',
|
||||
'LabelMapEditWithContour',
|
||||
],
|
||||
ContourTools: [
|
||||
'PlanarFreehandContourSegmentationTool',
|
||||
'SculptorTool',
|
||||
'SplineContourSegmentationTool',
|
||||
'LivewireContourSegmentationTool',
|
||||
],
|
||||
|
||||
LabelMapUtilities: ['InterpolateLabelmap', 'SegmentBidirectional'],
|
||||
ContourUtilities: ['LogicalContourOperations', 'SimplifyContours', 'SmoothContours'],
|
||||
|
||||
BrushTools: ['Brush', 'Eraser', 'Threshold'],
|
||||
};
|
||||
|
||||
/**
|
||||
* The segmentation mode's main toolbar layout (primary bar and viewport
|
||||
* action corners). Kept separate from the toolbox wiring above so other modes
|
||||
* can adopt segmentation editing without adopting this mode layout.
|
||||
*/
|
||||
export const segmentationModeToolbarSections: Record<string, string[]> = {
|
||||
[TOOLBAR_SECTIONS.primary]: [
|
||||
'WindowLevel',
|
||||
'Pan',
|
||||
'Zoom',
|
||||
'TrackballRotate',
|
||||
'Capture',
|
||||
'Layout',
|
||||
'Crosshairs',
|
||||
'MoreTools',
|
||||
],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'],
|
||||
|
||||
AdvancedRenderingControls: [
|
||||
'windowLevelMenuEmbedded',
|
||||
'voiManualControlMenu',
|
||||
'Colorbar',
|
||||
'opacityMenu',
|
||||
'thresholdMenu',
|
||||
],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.topRight]: [
|
||||
'modalityLoadBadge',
|
||||
'trackingStatus',
|
||||
'navigationComponent',
|
||||
],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: ['windowLevelMenu'],
|
||||
|
||||
MoreTools: [
|
||||
'Reset',
|
||||
'rotate-right',
|
||||
'flipHorizontal',
|
||||
'ReferenceLines',
|
||||
'ImageOverlayViewer',
|
||||
'StackScroll',
|
||||
'invert',
|
||||
'Cine',
|
||||
'Magnify',
|
||||
'TagBrowser',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Segmentation capability packs registered (at default scope) by the
|
||||
* cornerstone extension. These are pure "what can exist" packs and carry no
|
||||
* mode identity:
|
||||
* - `cornerstone.segmentationToolbarButtons` – segmentation editing button definitions
|
||||
* - `cornerstone.segmentationToolbarSections` – toolbox/utilities section wiring
|
||||
* - `cornerstone.segmentationModeToolbarSections` – a reusable segmentation-mode toolbar layout
|
||||
*
|
||||
* Modes compose these by name in their own `toolbarButtons` /
|
||||
* `toolbarSections` instance arrays; `?customization=` modules extend the
|
||||
* result through the `mode` phase.
|
||||
*/
|
||||
const segmentationToolbarCustomization = {
|
||||
'cornerstone.segmentationToolbarButtons': segmentationToolbarButtons,
|
||||
'cornerstone.segmentationToolbarSections': segmentationToolboxSections,
|
||||
'cornerstone.segmentationModeToolbarSections': segmentationModeToolbarSections,
|
||||
};
|
||||
|
||||
export { segmentationToolbarButtons };
|
||||
export default segmentationToolbarCustomization;
|
||||
@ -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;
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { toolGroupIds } from './initToolGroups';
|
||||
import { ToolbarService } from '@ohif/core';
|
||||
import i18n from 'i18next';
|
||||
|
||||
import { MIN_SEGMENTATION_DRAWING_RADIUS, MAX_SEGMENTATION_DRAWING_RADIUS } from './constants';
|
||||
import { toolGroupIds } from '../toolGroupIds';
|
||||
|
||||
const { TOOLBAR_SECTIONS } = ToolbarService;
|
||||
|
||||
export const MIN_SEGMENTATION_DRAWING_RADIUS = 0.5;
|
||||
export const MAX_SEGMENTATION_DRAWING_RADIUS = 99.5;
|
||||
|
||||
const setToolActiveToolbar = {
|
||||
commandName: 'setToolActiveToolbar',
|
||||
@ -10,6 +15,11 @@ const setToolActiveToolbar = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Default toolbar buttons for the TMTV mode, registered as the
|
||||
* `tmtv.toolbarButtons` customization so `?customization=` modules can extend
|
||||
* or replace them.
|
||||
*/
|
||||
const toolbarButtons = [
|
||||
{
|
||||
id: 'MeasurementTools',
|
||||
@ -301,7 +311,11 @@ const toolbarButtons = [
|
||||
evaluate: [
|
||||
{
|
||||
name: 'evaluate.cornerstone.segmentation',
|
||||
toolNames: ['ThresholdCircularBrush', 'ThresholdSphereBrush', 'ThresholdCircularBrushDynamic'],
|
||||
toolNames: [
|
||||
'ThresholdCircularBrush',
|
||||
'ThresholdSphereBrush',
|
||||
'ThresholdCircularBrushDynamic',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'evaluate.cornerstone.segmentation.synchronizeDrawingRadius',
|
||||
@ -458,4 +472,51 @@ const toolbarButtons = [
|
||||
},
|
||||
];
|
||||
|
||||
export default toolbarButtons;
|
||||
/**
|
||||
* Default toolbar layout for the TMTV mode, registered as the
|
||||
* `tmtv.toolbarSections` customization.
|
||||
*/
|
||||
export const toolbarSections: Record<string, string[]> = {
|
||||
[TOOLBAR_SECTIONS.primary]: ['MeasurementTools', 'Zoom', 'Pan', 'WindowLevel', 'Crosshairs'],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'],
|
||||
|
||||
AdvancedRenderingControls: [
|
||||
'windowLevelMenuEmbedded',
|
||||
'voiManualControlMenu',
|
||||
'Colorbar',
|
||||
'opacityMenu',
|
||||
'thresholdMenu',
|
||||
],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.topRight]: [
|
||||
'modalityLoadBadge',
|
||||
'trackingStatus',
|
||||
'navigationComponent',
|
||||
],
|
||||
|
||||
[TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: ['windowLevelMenu'],
|
||||
|
||||
MeasurementTools: ['Length', 'Bidirectional', 'ArrowAnnotate', 'EllipticalROI'],
|
||||
|
||||
ROIThresholdToolbox: ['SegmentationTools'],
|
||||
SegmentationTools: ['RectangleROIStartEndThreshold', 'BrushTools'],
|
||||
BrushTools: ['Brush', 'Eraser', 'Threshold'],
|
||||
};
|
||||
|
||||
/**
|
||||
* TMTV-specific capability packs registered (at default scope) by the tmtv
|
||||
* extension. These are the TMTV mode's specialized toolbar buttons and layout;
|
||||
* the TMTV mode composes them by name in its own `toolbarButtons` /
|
||||
* `toolbarSections` instance arrays, and `?customization=` modules extend the
|
||||
* result through the `mode` phase.
|
||||
*/
|
||||
const toolbarCustomization = {
|
||||
'tmtv.toolbarButtons': toolbarButtons,
|
||||
'tmtv.toolbarSections': toolbarSections,
|
||||
};
|
||||
|
||||
export { toolbarButtons };
|
||||
export default toolbarCustomization;
|
||||
17
extensions/tmtv/src/getCustomizationModule.ts
Normal file
17
extensions/tmtv/src/getCustomizationModule.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import toolbarCustomization from './customizations/toolbarCustomization';
|
||||
|
||||
/**
|
||||
* Registers the TMTV-specific capability packs (toolbar buttons and section
|
||||
* layout) so the TMTV mode can compose them by name and `?customization=`
|
||||
* modules can extend the result through the `mode` phase.
|
||||
*/
|
||||
export default function getCustomizationModule() {
|
||||
return [
|
||||
{
|
||||
name: 'default',
|
||||
value: {
|
||||
...toolbarCustomization,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@ -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 };
|
||||
|
||||
14
extensions/tmtv/src/toolGroupIds.ts
Normal file
14
extensions/tmtv/src/toolGroupIds.ts
Normal file
@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The tool group ids used by the TMTV hanging protocols and mode. Defined in
|
||||
* the extension so both the extension (hanging protocol viewports, toolbar
|
||||
* buttons) and the mode (tool group creation) share one definition.
|
||||
*/
|
||||
export const toolGroupIds = {
|
||||
CT: 'ctToolGroup',
|
||||
PT: 'ptToolGroup',
|
||||
Fusion: 'fusionToolGroup',
|
||||
MIP: 'mipToolGroup',
|
||||
default: 'default',
|
||||
};
|
||||
|
||||
export default toolGroupIds;
|
||||
@ -40,6 +40,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "7.29.7",
|
||||
"@ohif/mode-basic": "workspace:*",
|
||||
"i18next": "17.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
|
||||
101
modes/basic/src/modeCustomization.ts
Normal file
101
modes/basic/src/modeCustomization.ts
Normal file
@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Helpers for applying a mode's toolbar / tool-group composition.
|
||||
*
|
||||
* The composition values (`toolbarButtons`, `toolbarSections`,
|
||||
* `toolGroupAdditions`) are ordinary customizations resolved through the
|
||||
* customization service. A mode lists the capability packs it uses with
|
||||
* `{ $reference: '<name>' }` markers; the service expands those at read time
|
||||
* (packs that are arrays are flattened into the surrounding list), and a site
|
||||
* `?customization=` module extends or replaces them with immutability-helper
|
||||
* commands (`$push` another `{ $reference }`, `$set` a hard-coded value, ...).
|
||||
* By the time these helpers run, the values passed in are already fully
|
||||
* resolved — so they only need to shape/register them.
|
||||
*/
|
||||
|
||||
/** Normalizes a value to an array (wrapping a single object, dropping nullish). */
|
||||
function toArray(value: unknown): any[] {
|
||||
if (value === undefined || value === null) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a mode's toolbar from its resolved composition.
|
||||
*
|
||||
* `toolbarButtons` is a flat list of button definitions registered with the
|
||||
* toolbar service. `toolbarSections` is one or more
|
||||
* `{ sectionKey: buttonIds[] }` objects shallow-merged in order (later values
|
||||
* win per section) and applied with `updateSection`.
|
||||
*/
|
||||
export function registerModeToolbar({ toolbarService }, { toolbarButtons, toolbarSections }): void {
|
||||
toolbarService.register(toArray(toolbarButtons));
|
||||
|
||||
const sections: Record<string, string[]> = Object.assign({}, ...toArray(toolbarSections));
|
||||
for (const [key, section] of Object.entries(sections)) {
|
||||
toolbarService.updateSection(key, section);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds extra tools to the tool groups a mode has already created.
|
||||
*
|
||||
* `toolGroupAdditions` is a resolved object mapping a tool group id to a list
|
||||
* of tool blocks (each a `{ active/passive/enabled/disabled }` object). Tool
|
||||
* groups the mode did not create are skipped, so a single additions object can
|
||||
* be shared between modes with different tool group sets.
|
||||
*/
|
||||
export function applyToolGroupAdditions({ toolGroupService }, toolGroupAdditions): void {
|
||||
if (!toolGroupAdditions) {
|
||||
return;
|
||||
}
|
||||
for (const additions of toArray(toolGroupAdditions)) {
|
||||
for (const [toolGroupId, toolBlocks] of Object.entries(additions)) {
|
||||
if (!toolGroupService.getToolGroup(toolGroupId)) {
|
||||
continue;
|
||||
}
|
||||
for (const tools of toArray(toolBlocks)) {
|
||||
toolGroupService.addToolsToToolGroup(toolGroupId, tools);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wires up a mode's ActivatePanel event triggers from data.
|
||||
*
|
||||
* `activatePanelTriggers` is a list of
|
||||
* `{ panelId, sourceServiceName, sourceEvents, forceActive? }` entries;
|
||||
* `sourceEvents` names are looked up in the source service's `EVENTS` map
|
||||
* (falling back to the raw value) so the whole entry is JSON-serializable and
|
||||
* can be supplied by a `?customization=` module or an extending mode.
|
||||
*
|
||||
* Returns the unsubscribe functions for the subscriptions created.
|
||||
*/
|
||||
export function addActivatePanelTriggers(
|
||||
{ servicesManager },
|
||||
activatePanelTriggers
|
||||
): (() => void)[] {
|
||||
const { panelService } = servicesManager.services;
|
||||
const unsubscriptions: (() => void)[] = [];
|
||||
for (const trigger of activatePanelTriggers ?? []) {
|
||||
const { panelId, sourceServiceName, sourceEvents, forceActive = true } = trigger;
|
||||
const sourcePubSubService = servicesManager.services[sourceServiceName];
|
||||
if (!sourcePubSubService) {
|
||||
console.warn(`addActivatePanelTriggers: no service registered for "${sourceServiceName}"`);
|
||||
continue;
|
||||
}
|
||||
const subscriptions = panelService.addActivatePanelTriggers(
|
||||
panelId,
|
||||
[
|
||||
{
|
||||
sourcePubSubService,
|
||||
sourceEvents: sourceEvents.map(name => sourcePubSubService.EVENTS?.[name] ?? name),
|
||||
},
|
||||
],
|
||||
forceActive
|
||||
);
|
||||
unsubscriptions.push(...subscriptions.map(subscription => () => subscription.unsubscribe()));
|
||||
}
|
||||
return unsubscriptions;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 };
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
// },
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
@ -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/',
|
||||
},
|
||||
|
||||
@ -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/',
|
||||
},
|
||||
|
||||
@ -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/',
|
||||
},
|
||||
|
||||
400
platform/app/public/customizations/index.html
Normal file
400
platform/app/public/customizations/index.html
Normal file
@ -0,0 +1,400 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>OHIF URL Customizations</title>
|
||||
<style>
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #f5f6f8;
|
||||
--card: #ffffff;
|
||||
--card-border: #e3e6ea;
|
||||
--text: #1a1f27;
|
||||
--muted: #5c6774;
|
||||
--accent: #2563eb;
|
||||
--accent-contrast: #ffffff;
|
||||
--code-bg: #eef1f5;
|
||||
--tag-bg: #e6edff;
|
||||
--tag-text: #2952cc;
|
||||
--bar-bg: #ffffff;
|
||||
}
|
||||
/* This catalog page follows the OS light/dark preference automatically. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg: #0e1116;
|
||||
--card: #171b22;
|
||||
--card-border: #2a3038;
|
||||
--text: #e6e9ee;
|
||||
--muted: #99a3b0;
|
||||
--accent: #4f8cff;
|
||||
--accent-contrast: #0e1116;
|
||||
--code-bg: #0b0e13;
|
||||
--tag-bg: #1c2740;
|
||||
--tag-text: #8fb2ff;
|
||||
--bar-bg: #12161c;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.5;
|
||||
padding-bottom: 160px;
|
||||
}
|
||||
.wrap { max-width: 900px; margin: 0 auto; padding: 32px 20px 20px; }
|
||||
.topbar { display: flex; align-items: flex-start; gap: 16px; }
|
||||
.topbar .titles { flex: 1; min-width: 0; }
|
||||
.theme-picker { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.theme-picker label { font-size: 0.8rem; color: var(--muted); }
|
||||
.theme-picker select {
|
||||
background: var(--card); border: 1px solid var(--card-border); border-radius: 8px;
|
||||
padding: 7px 10px; color: var(--text); font-size: 0.85rem; cursor: pointer;
|
||||
}
|
||||
header h1 { font-size: 1.7rem; margin: 0 0 6px; }
|
||||
header p { color: var(--muted); margin: 0 0 8px; max-width: 62ch; }
|
||||
code {
|
||||
background: var(--code-bg);
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.group { margin-top: 28px; }
|
||||
.group-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 4px; }
|
||||
.group-head h2 { font-size: 1.15rem; margin: 0; }
|
||||
.group-head .dir { color: var(--muted); font-size: 0.85rem; }
|
||||
.group-head .toggle {
|
||||
margin-left: auto; background: none; border: none; color: var(--accent);
|
||||
cursor: pointer; font-size: 0.82rem; padding: 2px 4px;
|
||||
}
|
||||
.group-desc { color: var(--muted); font-size: 0.9rem; margin: 0 0 12px; }
|
||||
.card {
|
||||
display: flex; gap: 12px; align-items: flex-start;
|
||||
background: var(--card); border: 1px solid var(--card-border);
|
||||
border-radius: 10px; padding: 14px 16px; margin-bottom: 10px;
|
||||
cursor: pointer; transition: border-color .12s ease;
|
||||
}
|
||||
.card:hover { border-color: var(--accent); }
|
||||
.card input { margin-top: 3px; width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; }
|
||||
.card .body { flex: 1; min-width: 0; }
|
||||
.card .title-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.card .name { font-weight: 600; }
|
||||
.card .param { color: var(--muted); font-size: 0.8rem; }
|
||||
.card .desc { color: var(--muted); font-size: 0.9rem; margin: 4px 0 0; }
|
||||
.tag {
|
||||
font-size: 0.7rem; font-weight: 600; letter-spacing: .02em;
|
||||
background: var(--tag-bg); color: var(--tag-text);
|
||||
padding: 2px 7px; border-radius: 999px; text-transform: uppercase;
|
||||
}
|
||||
.launch {
|
||||
position: fixed; bottom: 0; left: 0; right: 0;
|
||||
background: var(--bar-bg); border-top: 1px solid var(--card-border);
|
||||
box-shadow: 0 -4px 16px rgba(0,0,0,0.08);
|
||||
}
|
||||
.launch-inner { max-width: 900px; margin: 0 auto; padding: 14px 20px; }
|
||||
.launch-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.launch-label { font-size: 0.8rem; color: var(--muted); }
|
||||
.url-box {
|
||||
flex: 1; min-width: 240px;
|
||||
background: var(--code-bg); border: 1px solid var(--card-border); border-radius: 8px;
|
||||
padding: 9px 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 0.82rem; color: var(--text); overflow-x: auto; white-space: nowrap;
|
||||
}
|
||||
.base-input {
|
||||
background: var(--card); border: 1px solid var(--card-border); border-radius: 8px;
|
||||
padding: 8px 10px; color: var(--text); font-size: 0.85rem; width: 130px;
|
||||
}
|
||||
button.btn {
|
||||
border: none; border-radius: 8px; padding: 9px 16px; font-size: 0.88rem; font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: var(--accent-contrast); }
|
||||
.btn-primary[aria-disabled='true'] { opacity: .45; pointer-events: none; }
|
||||
.btn-ghost { background: transparent; color: var(--accent); border: 1px solid var(--card-border); }
|
||||
.count { font-size: 0.8rem; color: var(--muted); }
|
||||
a { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<div class="topbar">
|
||||
<div class="titles">
|
||||
<h1>URL Customizations</h1>
|
||||
</div>
|
||||
<div class="theme-picker">
|
||||
<label for="viewer-theme">Viewer theme</label>
|
||||
<select id="viewer-theme" title="OHIF viewer theme — added as ?theme= to the launch URL">
|
||||
<optgroup label="Tonal (surfaces carry the theme hue)">
|
||||
<option value="default">OHIF Blue (default)</option>
|
||||
<option value="orchid">Orchid</option>
|
||||
<option value="arctic">Arctic</option>
|
||||
<option value="verdant">Verdant</option>
|
||||
</optgroup>
|
||||
<optgroup label="Neutral (desaturated + one accent)">
|
||||
<option value="midnight">Midnight</option>
|
||||
<option value="slate">Slate</option>
|
||||
<option value="deep">Deep</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
These are the example <code>?customization=</code> profiles shipped under
|
||||
<code>platform/app/public/customizations/</code>, grouped by area. Each file is fetched as
|
||||
JSONC <strong>data</strong> (never executed) and applied at startup. Tick the ones you want,
|
||||
optionally pick a viewer theme (added as <code>?theme=</code>), then launch the viewer or copy
|
||||
the generated query string onto any viewer URL. Multiple profiles are comma-separated and
|
||||
applied in order.
|
||||
</p>
|
||||
<p style="font-size: 0.85rem">
|
||||
See the
|
||||
<a href="https://docs.ohif.org/platform/services/customization-service/specificCustomizations">
|
||||
customization docs</a>
|
||||
for how to author your own. The prefix allowlist is set by
|
||||
<code>customizationUrlPrefixes</code> in the app config.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<main id="groups"></main>
|
||||
</div>
|
||||
|
||||
<div class="launch">
|
||||
<div class="launch-inner">
|
||||
<div class="launch-row" style="margin-bottom: 10px">
|
||||
<span class="launch-label">Viewer base</span>
|
||||
<input
|
||||
id="base"
|
||||
class="base-input"
|
||||
value="../"
|
||||
title="Base viewer URL the customization query is appended to (relative to this page)"
|
||||
/>
|
||||
<span class="count" id="count">0 selected</span>
|
||||
<button class="btn btn-ghost" id="clear" type="button">Clear all</button>
|
||||
<button class="btn btn-ghost" id="copy" type="button">Copy URL</button>
|
||||
<a class="btn btn-primary" id="launch" href="#" aria-disabled="true">Launch viewer →</a>
|
||||
</div>
|
||||
<div class="launch-row">
|
||||
<div class="url-box" id="url">Select one or more profiles above…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Catalog of shipped URL customizations, grouped by area/folder. The
|
||||
// `value` is the `?customization=` token (folder-relative path, no `.jsonc`).
|
||||
const CATALOG = [
|
||||
{
|
||||
dir: 'tools/',
|
||||
title: 'Tools & presets',
|
||||
desc: 'General viewport tools and presets. These apply globally (every mode).',
|
||||
items: [
|
||||
{
|
||||
value: 'tools/ctPresets',
|
||||
name: 'CT window/level presets',
|
||||
scope: 'global',
|
||||
desc: 'Replaces the CT presets in the window-level menu with a site-specific set (soft tissue, lung, angio, bone). Other modalities keep their defaults.',
|
||||
},
|
||||
{
|
||||
value: 'tools/smoothRotate',
|
||||
name: 'Smooth Rotate button',
|
||||
scope: 'global',
|
||||
desc: 'Adds a "Smooth Rotate" button to the More Tools menu that activates the PlanarRotate tool — drag to rotate the image freely (unlike the fixed 90° Rotate Right).',
|
||||
},
|
||||
{
|
||||
value: 'tools/measurementLabels',
|
||||
name: 'Predefined measurement labels',
|
||||
scope: 'global',
|
||||
desc: 'Prompts for a label from a fixed, exclusive list (Head, Shoulder, Knee, Toe) whenever a measurement is created.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
dir: 'worklist/',
|
||||
title: 'Study list',
|
||||
desc: 'Customizations for the WorkList / study browser.',
|
||||
items: [
|
||||
{
|
||||
value: 'worklist/patientBirthDate',
|
||||
name: 'Birth Date column',
|
||||
scope: 'global',
|
||||
desc: 'Adds a "Birth Date" column to the study list, immediately after the Patient column. Shipped configs load this by default via customizationService.requires.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
dir: 'segmentation/',
|
||||
title: 'Segmentation',
|
||||
desc: 'Per-mode composition of the segmentation capability packs.',
|
||||
items: [
|
||||
{
|
||||
value: 'segmentation/segmentationEditing',
|
||||
name: 'Segmentation editing',
|
||||
scope: 'basic / longitudinal',
|
||||
desc: 'Adds the segmentation editing toolbar, tools, and panels to the basic and longitudinal (viewer) modes, and enables editing in the segmentation panel.',
|
||||
},
|
||||
{
|
||||
value: 'segmentation/segmentationAnnotationTools',
|
||||
name: 'Annotation tools in segmentation mode',
|
||||
scope: 'segmentation',
|
||||
desc: 'Enables the measurement/annotation tools (Length, Bidirectional, ROIs, freehand, spline, livewire) inside the segmentation mode and adds the measurement panel.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
dir: 'veterinary/',
|
||||
title: 'Veterinary',
|
||||
desc: 'Veterinary-oriented overlay and a chaining profile that pulls it in.',
|
||||
items: [
|
||||
{
|
||||
value: 'veterinary/veterinary',
|
||||
name: 'Veterinary profile',
|
||||
scope: 'global',
|
||||
desc: 'Chaining profile — declares requires: ["veterinary/veterinaryOverlay"], so selecting this one alone pulls in and applies the overlay first.',
|
||||
},
|
||||
{
|
||||
value: 'veterinary/veterinaryOverlay',
|
||||
name: 'Veterinary viewport overlay',
|
||||
scope: 'global',
|
||||
desc: 'Overrides the viewport overlay with a veterinary-style demographics layout (patient/ID/date on the left, species/breed on the right).',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const groupsEl = document.getElementById('groups');
|
||||
const urlEl = document.getElementById('url');
|
||||
const launchEl = document.getElementById('launch');
|
||||
const countEl = document.getElementById('count');
|
||||
const baseEl = document.getElementById('base');
|
||||
const viewerThemeEl = document.getElementById('viewer-theme');
|
||||
|
||||
CATALOG.forEach((group, gi) => {
|
||||
const section = document.createElement('section');
|
||||
section.className = 'group';
|
||||
section.innerHTML =
|
||||
'<div class="group-head">' +
|
||||
'<h2>' + group.title + '</h2>' +
|
||||
'<span class="dir"><code>' + group.dir + '</code></span>' +
|
||||
'<button class="toggle" type="button" data-group="' + gi + '">Select all</button>' +
|
||||
'</div>' +
|
||||
'<p class="group-desc">' + group.desc + '</p>';
|
||||
|
||||
group.items.forEach(item => {
|
||||
const label = document.createElement('label');
|
||||
label.className = 'card';
|
||||
label.innerHTML =
|
||||
'<input type="checkbox" value="' + item.value + '" />' +
|
||||
'<span class="body">' +
|
||||
'<span class="title-row">' +
|
||||
'<span class="name">' + item.name + '</span>' +
|
||||
'<span class="tag">' + item.scope + '</span>' +
|
||||
'<span class="param"><code>?customization=' + item.value + '</code></span>' +
|
||||
'</span>' +
|
||||
'<span class="desc">' + item.desc + '</span>' +
|
||||
'</span>';
|
||||
section.appendChild(label);
|
||||
});
|
||||
groupsEl.appendChild(section);
|
||||
});
|
||||
|
||||
function selectedValues() {
|
||||
return Array.from(document.querySelectorAll('input[type=checkbox]:checked')).map(c => c.value);
|
||||
}
|
||||
|
||||
// Builds the launch URL from the selected profiles and viewer theme. The
|
||||
// `theme=` param is only added for a non-default preset (default is the
|
||||
// viewer's built-in look and needs no param). Returns null when there is
|
||||
// nothing to launch.
|
||||
function buildUrl(base, values, theme) {
|
||||
// Sanitize the DOM-derived inputs before they reach the launch href.
|
||||
// Customization tokens are `folder/name` segments, so allow word chars,
|
||||
// dot, dash and slash only; the theme is a plain preset id. Anything with
|
||||
// query metacharacters or a URL scheme (e.g. `javascript:`) is dropped.
|
||||
const TOKEN = /^[\w.-]+(?:\/[\w.-]+)*$/;
|
||||
const safeValues = values.filter(v => TOKEN.test(v));
|
||||
const safeTheme = /^[\w.-]+$/.test(theme || '') ? theme : 'default';
|
||||
if (!safeValues.length && (!safeTheme || safeTheme === 'default')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Resolve the (hand-typed) base against the current page and require an
|
||||
// http(s) target, so the base can never turn the href into a
|
||||
// javascript:/data: URL. searchParams.set() encodes the values safely.
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(base, window.location.href);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (safeValues.length) {
|
||||
parsed.searchParams.set('customization', safeValues.join(','));
|
||||
}
|
||||
if (safeTheme && safeTheme !== 'default') {
|
||||
parsed.searchParams.set('theme', safeTheme);
|
||||
}
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
const values = selectedValues();
|
||||
countEl.textContent = values.length + ' selected';
|
||||
const url = buildUrl(baseEl.value.trim() || '../', values, viewerThemeEl.value);
|
||||
if (!url) {
|
||||
urlEl.textContent = 'Select one or more profiles, or a viewer theme, above…';
|
||||
launchEl.setAttribute('aria-disabled', 'true');
|
||||
launchEl.removeAttribute('href');
|
||||
return;
|
||||
}
|
||||
urlEl.textContent = url;
|
||||
launchEl.href = url;
|
||||
launchEl.setAttribute('aria-disabled', 'false');
|
||||
}
|
||||
|
||||
groupsEl.addEventListener('change', refresh);
|
||||
baseEl.addEventListener('input', refresh);
|
||||
viewerThemeEl.addEventListener('change', refresh);
|
||||
|
||||
groupsEl.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.toggle');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
const section = btn.closest('.group');
|
||||
const boxes = section.querySelectorAll('input[type=checkbox]');
|
||||
const allChecked = Array.from(boxes).every(b => b.checked);
|
||||
boxes.forEach(b => (b.checked = !allChecked));
|
||||
btn.textContent = allChecked ? 'Select all' : 'Clear';
|
||||
refresh();
|
||||
});
|
||||
|
||||
document.getElementById('clear').addEventListener('click', () => {
|
||||
document.querySelectorAll('input[type=checkbox]').forEach(b => (b.checked = false));
|
||||
document.querySelectorAll('.toggle').forEach(t => (t.textContent = 'Select all'));
|
||||
refresh();
|
||||
});
|
||||
|
||||
document.getElementById('copy').addEventListener('click', async () => {
|
||||
const text = buildUrl(baseEl.value.trim() || '../', selectedValues(), viewerThemeEl.value);
|
||||
if (!text) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
const b = document.getElementById('copy');
|
||||
const prev = b.textContent;
|
||||
b.textContent = 'Copied!';
|
||||
setTimeout(() => (b.textContent = prev), 1200);
|
||||
} catch (_) {
|
||||
/* clipboard unavailable — the URL is visible in the box to copy manually */
|
||||
}
|
||||
});
|
||||
|
||||
refresh();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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": {
|
||||
@ -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": {
|
||||
@ -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": {
|
||||
@ -1,5 +0,0 @@
|
||||
// Example chaining module: ensures `veterinaryOverlay` is loaded and applied
|
||||
// first when using `?customization=veterinary` alone.
|
||||
{
|
||||
"requires": ["veterinaryOverlay"]
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
// Example chaining module: ensures `veterinary/veterinaryOverlay` is loaded and
|
||||
// applied first when using `?customization=veterinary/veterinary` alone.
|
||||
{
|
||||
"requires": ["veterinary/veterinaryOverlay"]
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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": {
|
||||
@ -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();
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -12,6 +12,7 @@ export const preserveKeys = [
|
||||
'screenNumber',
|
||||
'hangingProtocolId',
|
||||
'customization',
|
||||
'theme',
|
||||
];
|
||||
|
||||
function preserveKey(query: URLSearchParams, current: URLSearchParams, key: string) {
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -484,13 +484,98 @@ export default class CustomizationService extends PubSubService {
|
||||
this.globalCustomizations.get(customizationId) ??
|
||||
this.modeCustomizations.get(customizationId) ??
|
||||
this.defaultCustomizations.get(customizationId);
|
||||
const newTransformed = this.transform(customization);
|
||||
// Apply `inheritsFrom` / `$transform`, then expand any `$reference`
|
||||
// markers (see `_resolveReferences`). `seen` starts with the id being read
|
||||
// so a value that references itself is caught as a cycle.
|
||||
const newTransformed = this._resolveReferences(
|
||||
this.transform(customization),
|
||||
new Set([customizationId])
|
||||
);
|
||||
if (newTransformed !== undefined) {
|
||||
this.transformedCustomizations.set(customizationId, newTransformed);
|
||||
}
|
||||
return newTransformed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expands `$reference` markers inside a resolved customization value.
|
||||
*
|
||||
* A `{ $reference: '<name>' }` object is replaced by the value of the
|
||||
* customization `<name>` (itself resolved recursively, so references can
|
||||
* chain). References may appear anywhere in a value:
|
||||
* - as the whole value — an alias for another customization;
|
||||
* - as an item in an **array** — if the referenced value is itself an
|
||||
* array it is spread (flattened) into the parent, so a list can compose
|
||||
* several capability packs by name (e.g. a mode's `toolbarButtons`);
|
||||
* - as a property value of a **plain object** (e.g. each list under a
|
||||
* `toolGroupAdditions` map).
|
||||
*
|
||||
* Because expansion happens at read time (not when customizations are
|
||||
* merged), a later `$set` replaces the reference wholesale — with a different
|
||||
* `{ $reference }` or a hard-coded value — and edits to the referenced target
|
||||
* are picked up live. Only plain arrays/objects are walked; class instances,
|
||||
* functions and React elements are returned untouched, and unchanged values
|
||||
* are returned by identity so non-referencing customizations are not cloned.
|
||||
* Cycles are broken and warned via `seen`.
|
||||
*/
|
||||
private _resolveReferences(value: any, seen: Set<string>): any {
|
||||
if (!value || typeof value !== 'object' || value.$$typeof) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value.$reference === 'string') {
|
||||
return this._resolveReferenceName(value.$reference, seen);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
let changed = false;
|
||||
const result: any[] = [];
|
||||
for (const item of value) {
|
||||
if (item && typeof item === 'object' && !item.$$typeof && typeof item.$reference === 'string') {
|
||||
changed = true;
|
||||
const resolved = this._resolveReferenceName(item.$reference, seen);
|
||||
if (Array.isArray(resolved)) {
|
||||
result.push(...resolved);
|
||||
} else if (resolved !== undefined) {
|
||||
result.push(resolved);
|
||||
}
|
||||
} else {
|
||||
const resolved = this._resolveReferences(item, seen);
|
||||
changed ||= resolved !== item;
|
||||
result.push(resolved);
|
||||
}
|
||||
}
|
||||
return changed ? result : value;
|
||||
}
|
||||
if (!isPlainObject(value)) {
|
||||
return value;
|
||||
}
|
||||
let changed = false;
|
||||
const result: Record<string, any> = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
const resolved = this._resolveReferences(val, seen);
|
||||
changed ||= resolved !== val;
|
||||
result[key] = resolved;
|
||||
}
|
||||
return changed ? result : value;
|
||||
}
|
||||
|
||||
/** Resolves a single `$reference` target name, guarding against cycles. */
|
||||
private _resolveReferenceName(name: string, seen: Set<string>): any {
|
||||
if (seen.has(name)) {
|
||||
console.warn(`CustomizationService: $reference cycle detected at "${name}"`);
|
||||
return undefined;
|
||||
}
|
||||
const raw =
|
||||
this.globalCustomizations.get(name) ??
|
||||
this.modeCustomizations.get(name) ??
|
||||
this.defaultCustomizations.get(name);
|
||||
if (raw === undefined) {
|
||||
console.warn(`CustomizationService: no customization registered for $reference "${name}"`);
|
||||
return undefined;
|
||||
}
|
||||
const nextSeen = new Set(seen).add(name);
|
||||
return this._resolveReferences(this.transform(raw), nextSeen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a customization value, or the provided fallback when unset.
|
||||
*/
|
||||
@ -1112,6 +1197,15 @@ export function normalizeCustomizationConfig(configuration: unknown): {
|
||||
return {};
|
||||
}
|
||||
|
||||
/** True for `{}`-literal / null-prototype objects (not arrays or class instances). */
|
||||
function isPlainObject(value: any): boolean {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
return proto === Object.prototype || proto === null;
|
||||
}
|
||||
|
||||
function hasDollarKey(value) {
|
||||
if (Array.isArray(value)) {
|
||||
for (const item of value) {
|
||||
@ -1128,7 +1222,11 @@ function hasDollarKey(value) {
|
||||
return false;
|
||||
}
|
||||
for (const key of Object.keys(value)) {
|
||||
if (key.startsWith('$') && key !== '$transform') {
|
||||
// `$transform` and `$reference` are read-time markers resolved by the
|
||||
// service (in `transform` / `_resolveReferences`), not immutability-helper
|
||||
// merge commands — so a value carrying them is stored verbatim rather than
|
||||
// being run through `update()`.
|
||||
if (key.startsWith('$') && key !== '$transform' && key !== '$reference') {
|
||||
return true;
|
||||
}
|
||||
if (hasDollarKey(value[key])) {
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
@ -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).
|
||||
@ -225,15 +225,51 @@ export default mode;
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
enableSegmentationEdit
|
||||
excludedModalities
|
||||
</td>
|
||||
<td align="left">Boolean to skip the segmentation edit capabilities</td>
|
||||
<td align="left">The default isValidMode returns false when the modalities list contains ANY of these</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
excludedStudies
|
||||
</td>
|
||||
<td align="left">A list of study attribute objects; the default isValidMode returns false for a study matching every attribute of any entry, e.g. <code>[{ mrn: 'M1' }]</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
toolbarSections
|
||||
</td>
|
||||
<td align="left">An object containing toolbar section definitions to register</td>
|
||||
<td align="left">Toolbar section composition: a list of section-layout packs (as <code>$reference</code> markers) and/or literal section objects; seeded onto the Mode scope on enter</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
toolbarButtons
|
||||
</td>
|
||||
<td align="left">Toolbar button composition: a list of button packs (as <code>$reference</code> markers) and/or literal button definitions; seeded onto the Mode scope on enter</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
toolGroupAdditions
|
||||
</td>
|
||||
<td align="left">Per-tool-group composition: a map of tool-group id to a list of tool packs (as <code>$reference</code> markers) and/or literal tool blocks, layered onto the mode's tool groups after creation</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
initToolGroups
|
||||
</td>
|
||||
<td align="left">Tool group setup function called by the shared onModeEnter as <code>initToolGroups({ extensionManager, toolGroupService, commandsManager, servicesManager })</code>; extending modes can substitute their own</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
modeCustomizations
|
||||
</td>
|
||||
<td align="left">The mode's own customizations, applied by the mode route as the bottom layer of the mode scope on enter — before the app config / URL <code>mode</code> phase blocks, and below global-scope customizations, so final values are decided purely by scope precedence and application order. Usually the name of a block the extension registers at default scope (e.g. <code>basicModeCustomizations</code>, which sets <code>panelSegmentation.disableEditing</code>); may also be a literal object of immutability-helper commands or an array mixing those with customization module reference strings</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left">
|
||||
activatePanelTriggers
|
||||
</td>
|
||||
<td align="left">Data-driven ActivatePanel event triggers: a list of <code>{ panelId, sourceServiceName, sourceEvents, forceActive? }</code> entries wired up on mode enter (e.g. activating the segmentation panel when a segmentation is added). Empty by default; see <code>defaultActivatePanelTriggers</code> in the basic mode</td>
|
||||
</tr>
|
||||
|
||||
|
||||
@ -249,6 +285,73 @@ some default functions which can be used to create your own modes. Doing a mode
|
||||
this way makes the definition of new modes based on your existing mode much easier,
|
||||
and the upgrade to new versions of modes tends to be more consistent.
|
||||
|
||||
The **`segmentation`** and **`tmtv`** modes now follow this same pattern. Like
|
||||
`longitudinal`, each one exports a `modeInstance` object and reuses the `basic`
|
||||
mode's `modeFactory`, so they are extensible in two complementary ways:
|
||||
|
||||
1. **Build a derived mode** — create a new mode package (for example
|
||||
`mySegmentation`) that imports the shipped mode and overrides only the parts
|
||||
you need, exactly like `longitudinal` builds on `basic`.
|
||||
2. **Customize an existing mode at runtime** — change a shipped mode's toolbar,
|
||||
tools, or panels through per-mode customization keys, with no new package.
|
||||
|
||||
#### Building a derived mode
|
||||
|
||||
A derived mode imports the shipped mode's default export (which carries the
|
||||
`modeFactory`) and its `modeInstance`, then spreads and overrides. Because the
|
||||
default `modeFactory` applies [immutability-helper][immutability-helper]
|
||||
commands from `modeConfiguration` onto `modeInstance`, you can also override
|
||||
via `modeConfiguration` rather than editing the instance directly.
|
||||
|
||||
```js title="modes/my-segmentation/src/index.tsx"
|
||||
import segmentationMode, { modeInstance as segModeInstance } from '@ohif/mode-segmentation';
|
||||
|
||||
const id = 'mySegmentation';
|
||||
|
||||
export const modeInstance = {
|
||||
...segModeInstance,
|
||||
id,
|
||||
routeName: 'mySegmentation',
|
||||
displayName: 'My Segmentation',
|
||||
// Override only what you need. Toolbar buttons/sections and tool group
|
||||
// additions are plain composition arrays on the instance naming the
|
||||
// capability packs the mode uses (see below); panel lists are literal
|
||||
// arrays in the layout. Both are customized at runtime via the `mode` phase.
|
||||
};
|
||||
|
||||
const mode = {
|
||||
...segmentationMode, // carries modeFactory + extensionDependencies
|
||||
id,
|
||||
modeInstance,
|
||||
};
|
||||
|
||||
export default mode;
|
||||
```
|
||||
|
||||
The `tmtv` mode is extended the same way — import `@ohif/mode-tmtv` and its
|
||||
`modeInstance`, then override.
|
||||
|
||||
#### Customizing a mode at runtime
|
||||
|
||||
The `basic`, `longitudinal`, `segmentation`, and `tmtv` modes declare their toolbar
|
||||
buttons, toolbar sections, and tool-group additions as plain composition arrays on
|
||||
the mode instance that name the capability packs the mode uses with `{ $reference }`
|
||||
markers (for example
|
||||
`toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }, { $reference: 'cornerstone.segmentationToolbarButtons' }]`).
|
||||
The mode route seeds these onto the Mode customization scope on enter, alongside the
|
||||
`leftPanels` / `rightPanels` panel lists. Because the values are lists whose
|
||||
`{ $reference }` entries the customization service expands at read time, a `window.config`
|
||||
entry or a `?customization=` JSON module can add a whole capability pack (such as the
|
||||
segmentation editing tools), remove a default, or swap the panels — targeting the mode
|
||||
through a `mode` phase block (`mode.basic`, `mode.segmentation`, ...) without building
|
||||
a new mode. See [Compose whole capability blocks into a mode][compose-capability-blocks] in the
|
||||
Customization Service docs for the full key table, the reusable capability
|
||||
blocks, and worked examples (adding segmentation editing to the basic and
|
||||
longitudinal modes, and enabling annotation tools inside the segmentation mode).
|
||||
|
||||
[immutability-helper]: https://github.com/kolodny/immutability-helper
|
||||
[compose-capability-blocks]: ../services/customization-service/specificCustomizations.md#4-compose-whole-capability-blocks-into-a-mode
|
||||
|
||||
### Consuming Extensions
|
||||
|
||||
As mentioned in the [Extensions](../extensions/index.md) section, in `OHIF-v3`
|
||||
|
||||
@ -107,10 +107,13 @@ A URL-loaded file applies its `global` payload as **global customizations** —
|
||||
`window.config`'s `customizationService` entries, but loaded at runtime from `?customization=`. Any
|
||||
customization key that is read through `customizationService.getCustomization(...)` can therefore be
|
||||
set this way. The examples below are complete files; drop one under `platform/app/public/customizations/`
|
||||
(the `default` prefix) and load it with `?customization=<fileName>`. Because the files are JSONC, you
|
||||
can keep `//` comments and trailing commas in them.
|
||||
(the `default` prefix) and load it with `?customization=<path>`. The shipped examples are grouped into
|
||||
subfolders by area (e.g. `tools/`, `worklist/`, `segmentation/`, `veterinary/`), and a nested path is
|
||||
just part of the name under the `default` prefix — `?customization=tools/ctPresets` resolves to
|
||||
`./customizations/tools/ctPresets.jsonc`. Because the files are JSONC, you can keep `//` comments
|
||||
and trailing commas in them.
|
||||
|
||||
The shipped [`veterinaryOverlay.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/veterinaryOverlay.jsonc)
|
||||
The shipped [`veterinary/veterinaryOverlay.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/veterinary/veterinaryOverlay.jsonc)
|
||||
demonstrates a fourth scenario — replacing the viewport overlay layout via `viewportOverlay.topLeft` /
|
||||
`viewportOverlay.topRight`.
|
||||
|
||||
@ -120,7 +123,7 @@ Override the CT presets offered in the window-level menu (key: `cornerstone.wind
|
||||
`$merge` replaces only the `CT` entry, so presets for other modalities (PT, etc.) are kept.
|
||||
|
||||
```jsonc
|
||||
// platform/app/public/customizations/ctPresets.jsonc -> ?customization=ctPresets
|
||||
// platform/app/public/customizations/tools/ctPresets.jsonc -> ?customization=tools/ctPresets
|
||||
{
|
||||
"global": {
|
||||
"cornerstone.windowLevelPresets": {
|
||||
@ -143,7 +146,7 @@ Make the viewer prompt for a label from a fixed list whenever a measurement is c
|
||||
(key: `measurementLabels`).
|
||||
|
||||
```jsonc
|
||||
// platform/app/public/customizations/measurementLabels.jsonc -> ?customization=measurementLabels
|
||||
// platform/app/public/customizations/tools/measurementLabels.jsonc -> ?customization=tools/measurementLabels
|
||||
{
|
||||
"global": {
|
||||
"measurementLabels": {
|
||||
@ -169,12 +172,12 @@ The basic and longitudinal viewers register their toolbar as customizations
|
||||
layout that maps each section to a list of button ids). A module can therefore add a button by
|
||||
`$push`-ing a definition onto `cornerstone.toolbarButtons` and the button's id onto a section.
|
||||
|
||||
The shipped [`smoothRotate.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/smoothRotate.jsonc)
|
||||
The shipped [`tools/smoothRotate.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/tools/smoothRotate.jsonc)
|
||||
adds a **Smooth Rotate** button to the *More Tools* menu that activates the cornerstone `PlanarRotate`
|
||||
tool (drag to rotate the image freely, unlike the fixed 90° *Rotate Right*):
|
||||
|
||||
```jsonc
|
||||
// platform/app/public/customizations/smoothRotate.jsonc -> ?customization=smoothRotate
|
||||
// platform/app/public/customizations/tools/smoothRotate.jsonc -> ?customization=tools/smoothRotate
|
||||
{
|
||||
"global": {
|
||||
"cornerstone.toolbarButtons": {
|
||||
@ -207,10 +210,97 @@ tool (drag to rotate the image freely, unlike the fixed 90° *Rotate Right*):
|
||||
> module applies at the *global* scope, the `$push` **extends** the built-in buttons rather than
|
||||
> replacing them. The same pattern works for any tool already in the active tool group.
|
||||
|
||||
#### 4. Compose whole capability blocks into a mode
|
||||
|
||||
Ownership is split into three layers. **Extensions** export reusable *capability packs* — button
|
||||
definitions, section layouts and tool lists — under their own namespace (`cornerstone.*`,
|
||||
`tmtv.*`); the packs carry no mode identity. **Modes** own *composition*: each mode declares which
|
||||
packs it uses as plain arrays on its instance (`toolbarButtons`, `toolbarSections`,
|
||||
`toolGroupAdditions`), naming each pack with a `{ $reference: '<name>' }` marker, and the mode route
|
||||
seeds those onto the **Mode** customization scope on enter — exactly like it seeds
|
||||
`leftPanels` / `rightPanels` from the layout. **Config** (`?customization=`) re-composes an
|
||||
existing mode through the `mode` phase.
|
||||
|
||||
Because composition is per-mode, a JSON module targets a mode with a `mode` phase block keyed by the
|
||||
mode's id or route name and refines the plain concept keys — pushing a `{ $reference }` to a pack
|
||||
instead of restating its contents:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"mode": {
|
||||
"basic": {
|
||||
"toolbarButtons": { "$push": [{ "$reference": "cornerstone.segmentationToolbarButtons" }] },
|
||||
"toolGroupAdditions": {
|
||||
"default": { "$push": [{ "$reference": "cornerstone.segmentationTools" }] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
There are no `basic.*` / `segmentation.*` / `tmtv.*` keys — `mode.basic` / `mode.viewer` /
|
||||
`mode.segmentation` already select the mode, and the key is the concept (`toolbarButtons`), the same
|
||||
way `rightPanels` works for the sidebars. Reserve the `global` phase for values that truly apply
|
||||
to every mode.
|
||||
|
||||
**`$reference` — composing customizations by name.** A `{ "$reference": "<name>" }` object resolves,
|
||||
when the value is *read*, to the value of the customization `<name>`. References may sit anywhere in a
|
||||
value; inside an array a reference to another array is *flattened* in, so a list composes several
|
||||
packs by name. Because resolution is at read time (not when customizations merge), a later command
|
||||
composes naturally: `$push` adds another `{ $reference }`, and `$set` replaces the whole value —
|
||||
with a different `{ $reference }` **or** a hard-coded list:
|
||||
|
||||
```jsonc
|
||||
{ "mode": { "basic": {
|
||||
// swap the entire toolbar for a different pack …
|
||||
"toolbarButtons": { "$set": [{ "$reference": "myExtension.myToolbarButtons" }] }
|
||||
// … or for a hard-coded list of button definitions
|
||||
// "toolbarButtons": { "$set": [ { "id": "Length", /* … */ } ] }
|
||||
} } }
|
||||
```
|
||||
|
||||
Edits to the referenced pack itself are picked up live, and reference cycles are detected and warned.
|
||||
|
||||
Capability packs exported by the cornerstone extension:
|
||||
|
||||
- `cornerstone.toolbarButtons` / `cornerstone.toolbarSections` — the general viewer toolbar.
|
||||
- `cornerstone.segmentationToolbarButtons` / `cornerstone.segmentationToolbarSections` — the
|
||||
segmentation editing buttons and the toolbox section wiring rendered by the
|
||||
`panelSegmentationWithTools*` panels.
|
||||
- `cornerstone.segmentationModeToolbarSections` — a reusable segmentation-mode main toolbar layout.
|
||||
- `cornerstone.segmentationTools` — the segmentation editing tools (brushes, scissors,
|
||||
contour tools) as a `{ passive: [...] }` block for `toolGroupAdditions`.
|
||||
- `cornerstone.annotationTools` — the measurement/annotation tools as a
|
||||
`{ passive: [...] }` block for `toolGroupAdditions`.
|
||||
|
||||
The tmtv extension exports its TMTV-specific `tmtv.toolbarButtons` / `tmtv.toolbarSections` packs the
|
||||
same way.
|
||||
|
||||
Two shipped modules demonstrate the pattern:
|
||||
|
||||
- [`segmentation/segmentationEditing.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/segmentation/segmentationEditing.jsonc)
|
||||
(`?customization=segmentation/segmentationEditing`) adds segmentation editing to the basic and longitudinal
|
||||
modes: in `mode` phase blocks keyed by each mode's route name (`basic`, `viewer`) it `$push`es the
|
||||
segmentation button/section/tool packs onto that mode's `toolbarButtons` / `toolbarSections` /
|
||||
`toolGroupAdditions`, swaps the right panels via `rightPanels`, and enables editing via
|
||||
`panelSegmentation.disableEditing`.
|
||||
- [`segmentation/segmentationAnnotationTools.jsonc`](https://github.com/OHIF/Viewers/blob/master/platform/app/public/customizations/segmentation/segmentationAnnotationTools.jsonc)
|
||||
(`?customization=segmentation/segmentationAnnotationTools`) enables the annotation tools inside the
|
||||
segmentation mode: in the `mode.segmentation` block it adds a `MeasurementTools` section to the
|
||||
primary bar, `$push`es a `{ $reference }` to `cornerstone.annotationTools` onto
|
||||
`toolGroupAdditions`, and `$push`es the measurement panel onto `rightPanels`.
|
||||
|
||||
Each payload value uses [immutability-helper](https://github.com/kolodny/immutability-helper)
|
||||
commands (`$set`, `$push`, `$merge`, ...) exactly like `window.config` customizations, so a module can
|
||||
also append to a list or merge into an existing object rather than replacing it wholesale.
|
||||
|
||||
> **Every mode's panels are customizable with no opt-in.** A mode's layout declares
|
||||
> `leftPanels` / `rightPanels` as ordinary **arrays of panel ids** — the standard setup. On mode
|
||||
> enter the mode route seeds those arrays into the `leftPanels` / `rightPanels`
|
||||
> customizations at the bottom of the mode scope, then applies the `mode` phase blocks, then
|
||||
> resolves the sidebars from the final values — so commands compose with the mode's own list and
|
||||
> global-scope values win by scope precedence.
|
||||
|
||||
### URL modules, bootstrap, and client-side navigation (intended behavior)
|
||||
|
||||
Modules referenced from `?customization=` are loaded when the app applies URL customizations from
|
||||
|
||||
9
pnpm-lock.yaml
generated
9
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user