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
|
\* - For more information on different builds, check out our [Deploy
|
||||||
Docs][deployment-docs]
|
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
|
## Project
|
||||||
|
|
||||||
The OHIF Medical Image Viewing Platform is maintained as a
|
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-architecture]: https://docs.ohif.org/architecture/index.html
|
||||||
[ohif-extensions]: https://docs.ohif.org/architecture/index.html
|
[ohif-extensions]: https://docs.ohif.org/architecture/index.html
|
||||||
[deployment-docs]: https://docs.ohif.org/deployment/
|
[deployment-docs]: https://docs.ohif.org/deployment/
|
||||||
|
[config-file]: https://docs.ohif.org/configuration/configurationFiles
|
||||||
[react-url]: https://reactjs.org/
|
[react-url]: https://reactjs.org/
|
||||||
[pwa-url]: https://developers.google.com/web/progressive-web-apps/
|
[pwa-url]: https://developers.google.com/web/progressive-web-apps/
|
||||||
[ohif-viewer-url]: https://www.npmjs.com/package/@ohif/app
|
[ohif-viewer-url]: https://www.npmjs.com/package/@ohif/app
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import type { Button } from '@ohif/core/types';
|
import type { Button } from '@ohif/core/types';
|
||||||
import { ViewportGridService } from '@ohif/core';
|
import { ViewportGridService, ToolbarService } from '@ohif/core';
|
||||||
import i18n from 'i18next';
|
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 = {
|
const setToolActiveToolbar = {
|
||||||
commandName: 'setToolActiveToolbar',
|
commandName: 'setToolActiveToolbar',
|
||||||
@ -20,155 +23,16 @@ const callbacks = (toolName: string) => [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const toolbarButtons: Button[] = [
|
/**
|
||||||
{
|
* Segmentation editing toolbar buttons: the toolbox section containers plus
|
||||||
id: 'AdvancedRenderingControls',
|
* the labelmap / contour editing tools and utilities. These complement the
|
||||||
uiType: 'ohif.advancedRenderingControls',
|
* general buttons in `cornerstone.toolbarButtons`; together they are the
|
||||||
props: {
|
* default button set for the segmentation mode, and modes such as basic /
|
||||||
buttonSection: true,
|
* longitudinal can pull them in via a customization (see
|
||||||
},
|
* `segmentationEditing.jsonc`).
|
||||||
},
|
*/
|
||||||
{
|
const segmentationToolbarButtons: Button[] = [
|
||||||
id: 'modalityLoadBadge',
|
// section containers for the nested toolboxes and toolbars
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'BrushTools',
|
id: 'BrushTools',
|
||||||
uiType: 'ohif.toolBoxButtonGroup',
|
uiType: 'ohif.toolBoxButtonGroup',
|
||||||
@ -176,7 +40,6 @@ export const toolbarButtons: Button[] = [
|
|||||||
buttonSection: true,
|
buttonSection: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Section containers for the nested toolboxes and toolbars.
|
|
||||||
{
|
{
|
||||||
id: 'LabelMapUtilities',
|
id: 'LabelMapUtilities',
|
||||||
uiType: 'ohif.Toolbar',
|
uiType: 'ohif.Toolbar',
|
||||||
@ -206,215 +69,6 @@ export const toolbarButtons: Button[] = [
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
// tool defs
|
// 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',
|
id: 'PlanarFreehandContourSegmentationTool',
|
||||||
uiType: 'ohif.toolBoxButton',
|
uiType: 'ohif.toolBoxButton',
|
||||||
@ -559,7 +213,11 @@ export const toolbarButtons: Button[] = [
|
|||||||
value: 'CatmullRomSplineROI',
|
value: 'CatmullRomSplineROI',
|
||||||
label: i18n.t('Buttons:Catmull Rom Spline'),
|
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') },
|
{ id: 'BSplineROI', value: 'BSplineROI', label: i18n.t('Buttons:B-Spline') },
|
||||||
],
|
],
|
||||||
commands: {
|
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:
|
* Capability packs registered (at default scope) by the cornerstone extension:
|
||||||
* - `cornerstone.toolbarButtons` – the default toolbar button definitions
|
* - `cornerstone.toolbarButtons` – the general toolbar button definitions
|
||||||
* - `cornerstone.toolbarSections` – the default toolbar layout (section -> button ids)
|
* - `cornerstone.toolbarSections` – the general toolbar layout (section -> button ids)
|
||||||
*
|
*
|
||||||
* Modes read these by name in `onModeEnter`; URL `?customization=` modules can
|
* These are pure "what can exist" packs — they carry no mode identity. Modes
|
||||||
* extend them with immutability-helper commands (e.g. `$push` a new button).
|
* 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 = {
|
const toolbarButtonsCustomization = {
|
||||||
'cornerstone.toolbarButtons': toolbarButtons,
|
'cornerstone.toolbarButtons': toolbarButtons,
|
||||||
|
|||||||
@ -9,6 +9,8 @@ import colorbarCustomization from './customizations/colorbarCustomization';
|
|||||||
import modalityColorMapCustomization from './customizations/modalityColorMapCustomization';
|
import modalityColorMapCustomization from './customizations/modalityColorMapCustomization';
|
||||||
import windowLevelPresetsCustomization from './customizations/windowLevelPresetsCustomization';
|
import windowLevelPresetsCustomization from './customizations/windowLevelPresetsCustomization';
|
||||||
import toolbarButtonsCustomization from './customizations/toolbarButtonsCustomization';
|
import toolbarButtonsCustomization from './customizations/toolbarButtonsCustomization';
|
||||||
|
import segmentationToolbarCustomization from './customizations/segmentationToolbarCustomization';
|
||||||
|
import getToolGroupToolsCustomization from './customizations/toolGroupToolsCustomization';
|
||||||
import miscCustomization from './customizations/miscCustomization';
|
import miscCustomization from './customizations/miscCustomization';
|
||||||
import captureViewportModalCustomization from './customizations/captureViewportModalCustomization';
|
import captureViewportModalCustomization from './customizations/captureViewportModalCustomization';
|
||||||
import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization';
|
import viewportDownloadWarningCustomization from './customizations/viewportDownloadWarningCustomization';
|
||||||
@ -34,6 +36,8 @@ function getCustomizationModule({ commandsManager, servicesManager, extensionMan
|
|||||||
...modalityColorMapCustomization,
|
...modalityColorMapCustomization,
|
||||||
...windowLevelPresetsCustomization,
|
...windowLevelPresetsCustomization,
|
||||||
...toolbarButtonsCustomization,
|
...toolbarButtonsCustomization,
|
||||||
|
...segmentationToolbarCustomization,
|
||||||
|
...getToolGroupToolsCustomization({ commandsManager }),
|
||||||
...miscCustomization,
|
...miscCustomization,
|
||||||
...captureViewportModalCustomization,
|
...captureViewportModalCustomization,
|
||||||
...viewportDownloadWarningCustomization,
|
...viewportDownloadWarningCustomization,
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
import { toolGroupIds } from './initToolGroups';
|
import { ToolbarService } from '@ohif/core';
|
||||||
import i18n from 'i18next';
|
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 = {
|
const setToolActiveToolbar = {
|
||||||
commandName: '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 = [
|
const toolbarButtons = [
|
||||||
{
|
{
|
||||||
id: 'MeasurementTools',
|
id: 'MeasurementTools',
|
||||||
@ -301,7 +311,11 @@ const toolbarButtons = [
|
|||||||
evaluate: [
|
evaluate: [
|
||||||
{
|
{
|
||||||
name: 'evaluate.cornerstone.segmentation',
|
name: 'evaluate.cornerstone.segmentation',
|
||||||
toolNames: ['ThresholdCircularBrush', 'ThresholdSphereBrush', 'ThresholdCircularBrushDynamic'],
|
toolNames: [
|
||||||
|
'ThresholdCircularBrush',
|
||||||
|
'ThresholdSphereBrush',
|
||||||
|
'ThresholdCircularBrushDynamic',
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'evaluate.cornerstone.segmentation.synchronizeDrawingRadius',
|
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 init from './init';
|
||||||
import commandsModule from './commandsModule';
|
import commandsModule from './commandsModule';
|
||||||
import getToolbarModule from './getToolbarModule';
|
import getToolbarModule from './getToolbarModule';
|
||||||
|
import getCustomizationModule from './getCustomizationModule';
|
||||||
|
import { toolGroupIds } from './toolGroupIds';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
@ -19,6 +21,7 @@ const tmtvExtension = {
|
|||||||
getToolbarModule,
|
getToolbarModule,
|
||||||
getPanelModule,
|
getPanelModule,
|
||||||
getHangingProtocolModule,
|
getHangingProtocolModule,
|
||||||
|
getCustomizationModule,
|
||||||
getCommandsModule({ servicesManager, commandsManager, extensionManager }) {
|
getCommandsModule({ servicesManager, commandsManager, extensionManager }) {
|
||||||
return commandsModule({
|
return commandsModule({
|
||||||
servicesManager,
|
servicesManager,
|
||||||
@ -29,3 +32,4 @@ const tmtvExtension = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default 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": {
|
"dependencies": {
|
||||||
"@babel/runtime": "7.29.7",
|
"@babel/runtime": "7.29.7",
|
||||||
|
"@ohif/mode-basic": "workspace:*",
|
||||||
"i18next": "17.3.1"
|
"i18next": "17.3.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 toolbarButtons from './toolbarButtons';
|
||||||
import { id } from './id';
|
import { id } from './id';
|
||||||
import initToolGroups from './initToolGroups';
|
import initToolGroups from './initToolGroups';
|
||||||
import i18n from 'i18next';
|
|
||||||
|
const { TOOLBAR_SECTIONS } = ToolbarService;
|
||||||
|
|
||||||
// Allow this mode by excluding non-imaging modalities such as SR, SEG
|
// Allow this mode by excluding non-imaging modalities such as SR, SEG
|
||||||
// Also, SM is not a simple imaging modalities, so exclude it.
|
// Also, SM is not a simple imaging modalities, so exclude it.
|
||||||
@ -67,231 +77,178 @@ const extensionDependencies = {
|
|||||||
'@ohif/extension-test': '^0.0.1',
|
'@ohif/extension-test': '^0.0.1',
|
||||||
};
|
};
|
||||||
|
|
||||||
function modeFactory() {
|
/**
|
||||||
return {
|
* The test mode's toolbar layout, supplied as literal values rather than
|
||||||
id,
|
* `{ $reference }` capability-pack markers (the composition is resolved the
|
||||||
routeName: 'basic-test',
|
* same way either — literals pass through untouched).
|
||||||
displayName: i18n.t('Modes:Basic Test Mode'),
|
*/
|
||||||
/**
|
const toolbarSections = {
|
||||||
* Lifecycle hooks
|
[TOOLBAR_SECTIONS.primary]: [
|
||||||
*/
|
'MeasurementTools',
|
||||||
onModeEnter: ({ servicesManager, extensionManager, commandsManager }: withAppTypes) => {
|
'Zoom',
|
||||||
const { measurementService, toolbarService, toolGroupService, customizationService } =
|
'WindowLevelGroup',
|
||||||
servicesManager.services;
|
'Pan',
|
||||||
|
'Capture',
|
||||||
|
'Layout',
|
||||||
|
'MPR',
|
||||||
|
'Crosshairs',
|
||||||
|
'MoreTools',
|
||||||
|
],
|
||||||
|
|
||||||
measurementService.clearMeasurements();
|
WindowLevelGroup: ['WindowLevel', 'Soft tissue', 'Lung', 'Liver', 'Bone', 'Brain'],
|
||||||
|
|
||||||
// Init Default and SR ToolGroups
|
[TOOLBAR_SECTIONS.viewportActionMenu.topLeft]: ['orientationMenu', 'dataOverlayMenu'],
|
||||||
initToolGroups(extensionManager, toolGroupService, commandsManager);
|
|
||||||
|
|
||||||
// init customizations
|
[TOOLBAR_SECTIONS.viewportActionMenu.bottomMiddle]: ['AdvancedRenderingControls'],
|
||||||
customizationService.setCustomizations([
|
|
||||||
'@ohif/extension-test.customizationModule.custom-context-menu',
|
|
||||||
]);
|
|
||||||
|
|
||||||
toolbarService.register(toolbarButtons);
|
AdvancedRenderingControls: ['voiManualControlMenu', 'Colorbar', 'opacityMenu', 'thresholdMenu'],
|
||||||
console.debug('toolbarButtons', toolbarButtons);
|
|
||||||
toolbarService.updateSection(toolbarService.sections.primary, [
|
|
||||||
'MeasurementTools',
|
|
||||||
'Zoom',
|
|
||||||
'WindowLevelGroup',
|
|
||||||
'Pan',
|
|
||||||
'Capture',
|
|
||||||
'Layout',
|
|
||||||
'MPR',
|
|
||||||
'Crosshairs',
|
|
||||||
'MoreTools',
|
|
||||||
]);
|
|
||||||
|
|
||||||
toolbarService.updateSection('WindowLevelGroup', [
|
[TOOLBAR_SECTIONS.viewportActionMenu.topRight]: [
|
||||||
'WindowLevel',
|
'modalityLoadBadge',
|
||||||
'Soft tissue',
|
'trackingStatus',
|
||||||
'Lung',
|
'navigationComponent',
|
||||||
'Liver',
|
],
|
||||||
'Bone',
|
|
||||||
'Brain',
|
|
||||||
]);
|
|
||||||
|
|
||||||
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [
|
[TOOLBAR_SECTIONS.viewportActionMenu.bottomLeft]: ['windowLevelMenu'],
|
||||||
'orientationMenu',
|
|
||||||
'dataOverlayMenu',
|
|
||||||
]);
|
|
||||||
|
|
||||||
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomMiddle, [
|
MeasurementTools: [
|
||||||
'AdvancedRenderingControls',
|
'Length',
|
||||||
]);
|
'Bidirectional',
|
||||||
|
'ArrowAnnotate',
|
||||||
|
'EllipticalROI',
|
||||||
|
'CircleROI',
|
||||||
|
'PlanarFreehandROI',
|
||||||
|
'SplineROI',
|
||||||
|
'LivewireContour',
|
||||||
|
],
|
||||||
|
|
||||||
toolbarService.updateSection('AdvancedRenderingControls', [
|
MoreTools: [
|
||||||
'voiManualControlMenu',
|
'Reset',
|
||||||
'Colorbar',
|
'rotate-right',
|
||||||
'opacityMenu',
|
'flipHorizontal',
|
||||||
'thresholdMenu',
|
'ImageSliceSync',
|
||||||
]);
|
'ReferenceLines',
|
||||||
|
'ImageOverlayViewer',
|
||||||
|
'StackScroll',
|
||||||
|
'invert',
|
||||||
|
'Probe',
|
||||||
|
'Cine',
|
||||||
|
'Angle',
|
||||||
|
'CobbAngle',
|
||||||
|
'Magnify',
|
||||||
|
'RectangleROI',
|
||||||
|
'CalibrationLine',
|
||||||
|
'TagBrowser',
|
||||||
|
'AdvancedMagnify',
|
||||||
|
'UltrasoundDirectionalTool',
|
||||||
|
'WindowLevelRegion',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topRight, [
|
export const basicTestLayout = {
|
||||||
'modalityLoadBadge',
|
id: ohif.layout,
|
||||||
'trackingStatus',
|
props: {
|
||||||
'navigationComponent',
|
// Literal panel lists; the shared layout template also accepts
|
||||||
]);
|
// customization names here.
|
||||||
|
leftPanels: [tracked.thumbnailList],
|
||||||
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomLeft, [
|
leftPanelResizable: true,
|
||||||
'windowLevelMenu',
|
rightPanels: [cornerstone.panel, tracked.measurements, testExtension.measurements],
|
||||||
]);
|
rightPanelResizable: true,
|
||||||
|
viewports: [
|
||||||
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: [
|
|
||||||
{
|
{
|
||||||
path: 'basic-test',
|
namespace: tracked.viewport,
|
||||||
layoutTemplate: () => {
|
displaySetsToDisplay: [
|
||||||
return {
|
ohif.sopClassHandler,
|
||||||
id: ohif.layout,
|
dicomvideo.sopClassHandler,
|
||||||
props: {
|
ohif.wsiSopClassHandler,
|
||||||
leftPanels: [tracked.thumbnailList],
|
],
|
||||||
leftPanelResizable: true,
|
},
|
||||||
rightPanels: [cornerstone.panel, tracked.measurements, testExtension.measurements],
|
{
|
||||||
rightPanelResizable: true,
|
namespace: dicomsr.viewport,
|
||||||
viewports: [
|
displaySetsToDisplay: [dicomsr.sopClassHandler, dicomsr.sopClassHandler3D],
|
||||||
{
|
},
|
||||||
namespace: tracked.viewport,
|
{
|
||||||
displaySetsToDisplay: [
|
namespace: dicompdf.viewport,
|
||||||
ohif.sopClassHandler,
|
displaySetsToDisplay: [dicompdf.sopClassHandler],
|
||||||
dicomvideo.sopClassHandler,
|
},
|
||||||
ohif.wsiSopClassHandler,
|
{
|
||||||
],
|
namespace: dicomSeg.viewport,
|
||||||
},
|
displaySetsToDisplay: [dicomSeg.sopClassHandler],
|
||||||
{
|
},
|
||||||
namespace: dicomsr.viewport,
|
{
|
||||||
displaySetsToDisplay: [dicomsr.sopClassHandler, dicomsr.sopClassHandler3D],
|
namespace: dicomPmap.viewport,
|
||||||
},
|
displaySetsToDisplay: [dicomPmap.sopClassHandler],
|
||||||
{
|
|
||||||
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,
|
export const basicTestRoute = {
|
||||||
dicomSeg.sopClassHandler,
|
path: 'basic-test',
|
||||||
ohif.wsiSopClassHandler,
|
layoutTemplate,
|
||||||
ohif.sopClassHandler,
|
layoutInstance: basicTestLayout,
|
||||||
dicompdf.sopClassHandler,
|
};
|
||||||
dicomsr.sopClassHandler,
|
|
||||||
dicomsr.sopClassHandler3D,
|
/**
|
||||||
],
|
* Extends the basic mode instance: the shared onModeEnter/onModeExit are
|
||||||
hotkeys: {
|
* inherited, and the test specifics (toolbar layout, tool groups, test
|
||||||
name: 'basic-test-hotkeys',
|
* 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 = {
|
const mode = {
|
||||||
id,
|
id,
|
||||||
modeFactory,
|
modeFactory,
|
||||||
|
modeInstance,
|
||||||
extensionDependencies,
|
extensionDependencies,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default mode;
|
export default mode;
|
||||||
|
export { initToolGroups };
|
||||||
|
|||||||
@ -267,7 +267,7 @@ function initVolume3DToolGroup(extensionManager, toolGroupService) {
|
|||||||
toolGroupService.createToolGroupAndAddTools('volume3d', tools);
|
toolGroupService.createToolGroupAndAddTools('volume3d', tools);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initToolGroups(extensionManager, toolGroupService, commandsManager) {
|
function initToolGroups({ extensionManager, toolGroupService, commandsManager }) {
|
||||||
initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, 'default');
|
initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, 'default');
|
||||||
initSRToolGroup(extensionManager, toolGroupService, commandsManager);
|
initSRToolGroup(extensionManager, toolGroupService, commandsManager);
|
||||||
initMPRToolGroup(extensionManager, toolGroupService, commandsManager);
|
initMPRToolGroup(extensionManager, toolGroupService, commandsManager);
|
||||||
|
|||||||
@ -2,6 +2,11 @@ import update from 'immutability-helper';
|
|||||||
import { utils } from '@ohif/core';
|
import { utils } from '@ohif/core';
|
||||||
|
|
||||||
import initToolGroups from './initToolGroups';
|
import initToolGroups from './initToolGroups';
|
||||||
|
import {
|
||||||
|
addActivatePanelTriggers,
|
||||||
|
applyToolGroupAdditions,
|
||||||
|
registerModeToolbar,
|
||||||
|
} from './modeCustomization';
|
||||||
import { id } from './id';
|
import { id } from './id';
|
||||||
|
|
||||||
const { structuredCloneWithFunctions } = utils;
|
const { structuredCloneWithFunctions } = utils;
|
||||||
@ -99,20 +104,48 @@ export const sopClassHandlers = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Indicate this is a valid mode if:
|
* Data-driven mode validity check, using these mode instance properties:
|
||||||
* - it contains at least one of the modeModalities
|
* - `excludedStudies`: a list of `{ attribute: value }` objects; a study
|
||||||
* - it contains all of the array value in modeModalities
|
* matching every attribute of any entry is invalid.
|
||||||
* Otherwise, if modeModalities is not defined:
|
* - `excludedModalities`: the study is invalid when it contains ANY of these.
|
||||||
* - it contains at least one modality other than the nonModeMOdalities.
|
* - `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('\\');
|
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) {
|
if (this.modeModalities?.length) {
|
||||||
for (const modeModality of this.modeModalities) {
|
for (const modeModality of this.modeModalities) {
|
||||||
if (Array.isArray(modeModality) && modeModality.every(m => modalities.indexOf(m) !== -1)) {
|
if (Array.isArray(modeModality)) {
|
||||||
return { valid: true, description: `Matches ${modeModality.join(', ')}` };
|
if (modeModality.every(m => modalities_list.includes(m))) {
|
||||||
} else if (modalities.indexOf(modeModality)) {
|
return { valid: true, description: `Matches ${modeModality.join(', ')}` };
|
||||||
|
}
|
||||||
|
} else if (modalities_list.includes(modeModality)) {
|
||||||
return { valid: true, description: `Matches ${modeModality}` };
|
return { valid: true, description: `Matches ${modeModality}` };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -122,84 +155,81 @@ export function isValidMode({ modalities }) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nonModeModalities = this.nonModeModalities ?? [];
|
||||||
return {
|
return {
|
||||||
valid: !!modalities_list.find(modality => this.nonModeModalities.indexOf(modality) === -1),
|
valid: !!modalities_list.find(modality => !nonModeModalities.includes(modality)),
|
||||||
description: `The mode does not support studies that ONLY include the following modalities: ${this.nonModeModalities.join(', ')}`,
|
description: `The mode does not support studies that ONLY include the following modalities: ${nonModeModalities.join(', ')}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onModeEnter({
|
/**
|
||||||
servicesManager,
|
* The panel activation triggers the basic family of modes historically shipped
|
||||||
extensionManager,
|
* (commented out): activate the segmentation/measurement panel when a
|
||||||
commandsManager,
|
* segmentation/measurement is added. Not enabled by default; a mode or
|
||||||
panelService,
|
* customization can set them via the `activatePanelTriggers` instance
|
||||||
segmentationService,
|
* property.
|
||||||
}: withAppTypes) {
|
*/
|
||||||
|
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 } =
|
const { measurementService, toolbarService, toolGroupService, customizationService } =
|
||||||
servicesManager.services;
|
servicesManager.services;
|
||||||
|
|
||||||
measurementService.clearMeasurements();
|
measurementService.clearMeasurements();
|
||||||
|
|
||||||
// Init Default and SR ToolGroups
|
// Subscriptions the mode creates are tracked as unsubscribe functions on the
|
||||||
initToolGroups(extensionManager, toolGroupService, commandsManager);
|
// 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
|
// Init the mode's tool groups. The function is a mode instance property so
|
||||||
// (a string, resolved through the customization service so `?customization=`
|
// extending modes can substitute their own tool group setup.
|
||||||
// modules can extend the cornerstone-registered defaults) or as a literal
|
this.initToolGroups?.({ extensionManager, toolGroupService, commandsManager, servicesManager });
|
||||||
// value (the button array / sections object) for modes that define them inline.
|
|
||||||
const resolveToolbarCustomization = (value: unknown) =>
|
|
||||||
typeof value === 'string' ? customizationService.getCustomization(value) : value;
|
|
||||||
|
|
||||||
const toolbarButtons = resolveToolbarCustomization(this.toolbarButtons) as any;
|
// Toolbar buttons and layout come from the mode's composition, which the
|
||||||
const toolbarSections = (resolveToolbarCustomization(this.toolbarSections) ?? {}) as Record<
|
// mode route seeded onto the Mode customization scope on enter (the plain
|
||||||
string,
|
// `toolbarButtons` / `toolbarSections` keys) and the app config / URL `mode`
|
||||||
string[]
|
// 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)) {
|
// Note: the mode's `modeCustomizations` are NOT applied here — the mode
|
||||||
toolbarService.updateSection(key, section);
|
// 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) {
|
// ActivatePanel event triggers (e.g. activating the segmentation panel when
|
||||||
customizationService.setCustomizations({
|
// a segmentation is added). Off by default; supplied as data so extending
|
||||||
'panelSegmentation.disableEditing': {
|
// modes and customizations can point at their own panels.
|
||||||
$set: true,
|
this._unsubscriptions.push(
|
||||||
},
|
...addActivatePanelTriggers({ servicesManager }, this.activatePanelTriggers)
|
||||||
});
|
);
|
||||||
}
|
|
||||||
|
|
||||||
// // 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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onModeExit({ servicesManager }: withAppTypes) {
|
export function onModeExit({ servicesManager }: withAppTypes) {
|
||||||
@ -212,8 +242,8 @@ export function onModeExit({ servicesManager }: withAppTypes) {
|
|||||||
uiModalService,
|
uiModalService,
|
||||||
} = servicesManager.services;
|
} = servicesManager.services;
|
||||||
|
|
||||||
this._activatePanelTriggersSubscriptions.forEach(sub => sub.unsubscribe());
|
this._unsubscriptions?.forEach(unsubscribe => unsubscribe());
|
||||||
this._activatePanelTriggersSubscriptions.length = 0;
|
this._unsubscriptions = [];
|
||||||
|
|
||||||
uiDialogService.hideAll();
|
uiDialogService.hideAll();
|
||||||
uiModalService.hide();
|
uiModalService.hide();
|
||||||
@ -226,6 +256,11 @@ export function onModeExit({ servicesManager }: withAppTypes) {
|
|||||||
export const basicLayout = {
|
export const basicLayout = {
|
||||||
id: ohif.layout,
|
id: ohif.layout,
|
||||||
props: {
|
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],
|
leftPanels: [ohif.thumbnailList],
|
||||||
leftPanelResizable: true,
|
leftPanelResizable: true,
|
||||||
rightPanels: [cornerstone.segmentation, cornerstone.measurements],
|
rightPanels: [cornerstone.segmentation, cornerstone.measurements],
|
||||||
@ -284,11 +319,33 @@ export const modeInstance = {
|
|||||||
// instance by default.
|
// instance by default.
|
||||||
hide: false,
|
hide: false,
|
||||||
displayName: 'Non-Longitudinal Basic',
|
displayName: 'Non-Longitudinal Basic',
|
||||||
_activatePanelTriggersSubscriptions: [],
|
// Toolbar/tool-group composition: which capability packs this mode uses,
|
||||||
// Toolbar buttons and layout are referenced by customization name; the
|
// named with `{ $reference }` markers the customization service expands at
|
||||||
// cornerstone extension registers the defaults and `?customization=` modules
|
// read time. The mode route seeds these onto the Mode customization scope on
|
||||||
// can extend them. onModeEnter resolves these names via the customization service.
|
// enter (as the plain `toolbarButtons` / `toolbarSections` /
|
||||||
toolbarSections: 'cornerstone.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
|
* Lifecycle hooks
|
||||||
@ -310,8 +367,7 @@ export const modeInstance = {
|
|||||||
// general handler needs to come last. For this case, the dicomvideo must
|
// general handler needs to come last. For this case, the dicomvideo must
|
||||||
// come first to remove video transfer syntax before ohif uses images
|
// come first to remove video transfer syntax before ohif uses images
|
||||||
sopClassHandlers,
|
sopClassHandlers,
|
||||||
toolbarButtons: 'cornerstone.toolbarButtons',
|
toolbarButtons: [{ $reference: 'cornerstone.toolbarButtons' }],
|
||||||
enableSegmentationEdit: false,
|
|
||||||
nonModeModalities: NON_IMAGE_MODALITIES,
|
nonModeModalities: NON_IMAGE_MODALITIES,
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -327,12 +383,35 @@ export function modeFactory({ modeConfiguration }) {
|
|||||||
return modeInstance;
|
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 = {
|
export const mode = {
|
||||||
id,
|
id,
|
||||||
modeFactory,
|
modeFactory,
|
||||||
modeInstance: { ...modeInstance, hide: true },
|
modeInstance: { ...modeInstance, hide: true },
|
||||||
extensionDependencies,
|
extensionDependencies,
|
||||||
|
customizations,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default mode;
|
export default mode;
|
||||||
export { initToolGroups };
|
export { initToolGroups };
|
||||||
|
export {
|
||||||
|
addActivatePanelTriggers,
|
||||||
|
applyToolGroupAdditions,
|
||||||
|
registerModeToolbar,
|
||||||
|
} from './modeCustomization';
|
||||||
|
|||||||
@ -83,10 +83,7 @@ function initDefaultToolGroup(extensionManager, toolGroupService, commandsManage
|
|||||||
{ toolName: toolNames.LivewireContour },
|
{ toolName: toolNames.LivewireContour },
|
||||||
{ toolName: toolNames.WindowLevelRegion },
|
{ toolName: toolNames.WindowLevelRegion },
|
||||||
],
|
],
|
||||||
enabled: [
|
enabled: [{ toolName: toolNames.ImageOverlayViewer }, { toolName: toolNames.ReferenceLines }],
|
||||||
{ toolName: toolNames.ImageOverlayViewer },
|
|
||||||
{ toolName: toolNames.ReferenceLines },
|
|
||||||
],
|
|
||||||
disabled: [
|
disabled: [
|
||||||
{
|
{
|
||||||
toolName: toolNames.AdvancedMagnify,
|
toolName: toolNames.AdvancedMagnify,
|
||||||
@ -306,7 +303,12 @@ function initVolume3DToolGroup(extensionManager, toolGroupService) {
|
|||||||
toolGroupService.createToolGroupAndAddTools('volume3d', tools);
|
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');
|
initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, 'default');
|
||||||
initSRToolGroup(extensionManager, toolGroupService);
|
initSRToolGroup(extensionManager, toolGroupService);
|
||||||
initMPRToolGroup(extensionManager, toolGroupService, commandsManager);
|
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 i18n from 'i18next';
|
||||||
import { id } from './id';
|
import { id } from './id';
|
||||||
import { initToolGroups, cornerstone,
|
import {
|
||||||
|
initToolGroups,
|
||||||
ohif,
|
ohif,
|
||||||
|
cornerstone,
|
||||||
dicomsr,
|
dicomsr,
|
||||||
dicomvideo,
|
dicomvideo,
|
||||||
basicLayout,
|
basicLayout,
|
||||||
@ -9,7 +11,7 @@ import { initToolGroups, cornerstone,
|
|||||||
extensionDependencies as basicDependencies,
|
extensionDependencies as basicDependencies,
|
||||||
mode as basicMode,
|
mode as basicMode,
|
||||||
modeInstance as basicModeInstance,
|
modeInstance as basicModeInstance,
|
||||||
} from '@ohif/mode-basic';
|
} from '@ohif/mode-basic';
|
||||||
|
|
||||||
export const tracked = {
|
export const tracked = {
|
||||||
measurements: '@ohif/extension-measurement-tracking.panelModule.trackedMeasurements',
|
measurements: '@ohif/extension-measurement-tracking.panelModule.trackedMeasurements',
|
||||||
@ -28,6 +30,9 @@ export const longitudinalInstance = {
|
|||||||
id: ohif.layout,
|
id: ohif.layout,
|
||||||
props: {
|
props: {
|
||||||
...basicLayout.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],
|
leftPanels: [tracked.thumbnailList],
|
||||||
rightPanels: [cornerstone.segmentation, tracked.measurements],
|
rightPanels: [cornerstone.segmentation, tracked.measurements],
|
||||||
viewports: [
|
viewports: [
|
||||||
@ -37,33 +42,29 @@ export const longitudinalInstance = {
|
|||||||
displaySetsToDisplay: basicLayout.props.viewports[0].displaySetsToDisplay,
|
displaySetsToDisplay: basicLayout.props.viewports[0].displaySetsToDisplay,
|
||||||
},
|
},
|
||||||
...basicLayout.props.viewports,
|
...basicLayout.props.viewports,
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const longitudinalRoute = {
|
||||||
export const longitudinalRoute =
|
...basicRoute,
|
||||||
{
|
path: 'longitudinal',
|
||||||
...basicRoute,
|
/*init: ({ servicesManager, extensionManager }) => {
|
||||||
path: 'longitudinal',
|
|
||||||
/*init: ({ servicesManager, extensionManager }) => {
|
|
||||||
//defaultViewerRouteInit
|
//defaultViewerRouteInit
|
||||||
},*/
|
},*/
|
||||||
layoutInstance: longitudinalInstance,
|
layoutInstance: longitudinalInstance,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const modeInstance = {
|
export const modeInstance = {
|
||||||
...basicModeInstance,
|
...basicModeInstance,
|
||||||
// TODO: We're using this as a route segment
|
// TODO: We're using this as a route segment
|
||||||
// We should not be.
|
// We should not be.
|
||||||
id,
|
id,
|
||||||
routeName: 'viewer',
|
routeName: 'viewer',
|
||||||
displayName: i18n.t('Modes:Basic Viewer'),
|
displayName: i18n.t('Modes:Basic Viewer'),
|
||||||
routes: [
|
routes: [longitudinalRoute],
|
||||||
longitudinalRoute
|
extensions: extensionDependencies,
|
||||||
],
|
};
|
||||||
extensions: extensionDependencies,
|
|
||||||
};
|
|
||||||
|
|
||||||
const mode = {
|
const mode = {
|
||||||
...basicMode,
|
...basicMode,
|
||||||
|
|||||||
@ -1,249 +1,144 @@
|
|||||||
import { id } from './id';
|
import { id } from './id';
|
||||||
import toolbarButtons from './toolbarButtons';
|
|
||||||
import initToolGroups from './initToolGroups';
|
import initToolGroups from './initToolGroups';
|
||||||
import setUpAutoTabSwitchHandler from './utils/setUpAutoTabSwitchHandler';
|
import setUpAutoTabSwitchHandler from './utils/setUpAutoTabSwitchHandler';
|
||||||
import { ohif, cornerstone, extensionDependencies, dicomRT, segmentation } from '@ohif/mode-basic';
|
import {
|
||||||
export * from './toolbarButtons';
|
ohif,
|
||||||
|
cornerstone,
|
||||||
|
extensionDependencies,
|
||||||
|
dicomRT,
|
||||||
|
segmentation,
|
||||||
|
isValidMode,
|
||||||
|
onModeEnter as basicOnModeEnter,
|
||||||
|
onModeExit,
|
||||||
|
layoutTemplate,
|
||||||
|
modeFactory,
|
||||||
|
} from '@ohif/mode-basic';
|
||||||
|
|
||||||
function modeFactory({ modeConfiguration }) {
|
/**
|
||||||
const _unsubscriptions = [];
|
* Extends the basic mode enter with the segmentation panel auto tab switch
|
||||||
return {
|
* handling (switching between labelmap/contour panels as segmentations of the
|
||||||
/**
|
* relevant type become active).
|
||||||
* 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.
|
export function onModeEnter(ctx: withAppTypes) {
|
||||||
*/
|
basicOnModeEnter.call(this, ctx);
|
||||||
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;
|
|
||||||
|
|
||||||
measurementService.clearMeasurements();
|
const { segmentationService, viewportGridService, panelService } = ctx.servicesManager.services;
|
||||||
|
|
||||||
// Init Default and SR ToolGroups
|
const { unsubscribeAutoTabSwitchEvents } = setUpAutoTabSwitchHandler({
|
||||||
initToolGroups(extensionManager, toolGroupService, commandsManager);
|
segmentationService,
|
||||||
|
viewportGridService,
|
||||||
|
panelService,
|
||||||
|
});
|
||||||
|
|
||||||
toolbarService.register(toolbarButtons);
|
this._unsubscriptions.push(...unsubscribeAutoTabSwitchEvents);
|
||||||
|
|
||||||
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],
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = {
|
const mode = {
|
||||||
id,
|
id,
|
||||||
modeFactory,
|
modeFactory,
|
||||||
|
modeInstance,
|
||||||
extensionDependencies,
|
extensionDependencies,
|
||||||
|
customizations,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default mode;
|
export default mode;
|
||||||
|
export { initToolGroups };
|
||||||
|
|||||||
@ -258,7 +258,7 @@ function initVolume3DToolGroup(extensionManager, toolGroupService) {
|
|||||||
toolGroupService.createToolGroupAndAddTools('volume3d', tools);
|
toolGroupService.createToolGroupAndAddTools('volume3d', tools);
|
||||||
}
|
}
|
||||||
|
|
||||||
function initToolGroups(extensionManager, toolGroupService, commandsManager) {
|
function initToolGroups({ extensionManager, toolGroupService, commandsManager }) {
|
||||||
initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, 'default');
|
initDefaultToolGroup(extensionManager, toolGroupService, commandsManager, 'default');
|
||||||
initMPRToolGroup(extensionManager, toolGroupService, commandsManager);
|
initMPRToolGroup(extensionManager, toolGroupService, commandsManager);
|
||||||
initVolume3DToolGroup(extensionManager, toolGroupService);
|
initVolume3DToolGroup(extensionManager, toolGroupService);
|
||||||
|
|||||||
@ -35,7 +35,9 @@
|
|||||||
"@ohif/extension-default": "workspace:*",
|
"@ohif/extension-default": "workspace:*",
|
||||||
"@ohif/extension-dicom-pdf": "workspace:*",
|
"@ohif/extension-dicom-pdf": "workspace:*",
|
||||||
"@ohif/extension-dicom-video": "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": {
|
"dependencies": {
|
||||||
"@babel/runtime": "7.29.7",
|
"@babel/runtime": "7.29.7",
|
||||||
|
|||||||
@ -1,32 +1,39 @@
|
|||||||
import { classes } from '@ohif/core';
|
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 { id } from './id.js';
|
||||||
import initToolGroups from './initToolGroups.js';
|
import initToolGroups, { toolGroupIds } from './initToolGroups.js';
|
||||||
import setCrosshairsConfiguration from './utils/setCrosshairsConfiguration.js';
|
import setCrosshairsConfiguration from './utils/setCrosshairsConfiguration.js';
|
||||||
import setFusionActiveVolume from './utils/setFusionActiveVolume.js';
|
import setFusionActiveVolume from './utils/setFusionActiveVolume.js';
|
||||||
import i18n from 'i18next';
|
|
||||||
|
|
||||||
const { MetadataProvider } = classes;
|
const { MetadataProvider } = classes;
|
||||||
|
|
||||||
const ohif = {
|
export const ohif = {
|
||||||
layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
|
layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
|
||||||
sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack',
|
sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack',
|
||||||
thumbnailList: '@ohif/extension-default.panelModule.seriesList',
|
thumbnailList: '@ohif/extension-default.panelModule.seriesList',
|
||||||
};
|
};
|
||||||
|
|
||||||
const cs3d = {
|
export const cs3d = {
|
||||||
viewport: '@ohif/extension-cornerstone.viewportModule.cornerstone',
|
viewport: '@ohif/extension-cornerstone.viewportModule.cornerstone',
|
||||||
segPanel: '@ohif/extension-cornerstone.panelModule.panelSegmentationNoHeader',
|
segPanel: '@ohif/extension-cornerstone.panelModule.panelSegmentationNoHeader',
|
||||||
measurements: '@ohif/extension-cornerstone.panelModule.measurements',
|
measurements: '@ohif/extension-cornerstone.panelModule.measurements',
|
||||||
};
|
};
|
||||||
|
|
||||||
const tmtv = {
|
export const tmtv = {
|
||||||
hangingProtocol: '@ohif/extension-tmtv.hangingProtocolModule.ptCT',
|
hangingProtocol: '@ohif/extension-tmtv.hangingProtocolModule.ptCT',
|
||||||
petSUV: '@ohif/extension-tmtv.panelModule.petSUV',
|
petSUV: '@ohif/extension-tmtv.panelModule.petSUV',
|
||||||
tmtv: '@ohif/extension-tmtv.panelModule.tmtv',
|
tmtv: '@ohif/extension-tmtv.panelModule.tmtv',
|
||||||
};
|
};
|
||||||
|
|
||||||
const extensionDependencies = {
|
export const extensionDependencies = {
|
||||||
// Can derive the versions at least process.env.from npm_package_version
|
// Can derive the versions at least process.env.from npm_package_version
|
||||||
'@ohif/extension-default': '^3.0.0',
|
'@ohif/extension-default': '^3.0.0',
|
||||||
'@ohif/extension-cornerstone': '^3.0.0',
|
'@ohif/extension-cornerstone': '^3.0.0',
|
||||||
@ -34,234 +41,187 @@ const extensionDependencies = {
|
|||||||
'@ohif/extension-tmtv': '^3.0.0',
|
'@ohif/extension-tmtv': '^3.0.0',
|
||||||
};
|
};
|
||||||
|
|
||||||
const unsubscriptions = [];
|
/**
|
||||||
function modeFactory({ modeConfiguration }) {
|
* Extends the basic mode enter (tool groups, toolbar, tool group additions)
|
||||||
return {
|
* with the TMTV specifics: the fusion viewport crosshairs/active-volume
|
||||||
// TODO: We're using this as a route segment
|
* configuration and the PT VOI hanging protocol attribute.
|
||||||
// We should not be.
|
*/
|
||||||
id,
|
export function onModeEnter(ctx: withAppTypes) {
|
||||||
routeName: 'tmtv',
|
basicOnModeEnter.call(this, ctx);
|
||||||
displayName: i18n.t('Modes:Total Metabolic Tumor Volume'),
|
|
||||||
/**
|
|
||||||
* Lifecycle hooks
|
|
||||||
*/
|
|
||||||
onModeEnter: ({ servicesManager, extensionManager, commandsManager }: withAppTypes) => {
|
|
||||||
const {
|
|
||||||
toolbarService,
|
|
||||||
toolGroupService,
|
|
||||||
customizationService,
|
|
||||||
hangingProtocolService,
|
|
||||||
displaySetService,
|
|
||||||
} = servicesManager.services;
|
|
||||||
|
|
||||||
const utilityModule = extensionManager.getModuleEntry(
|
const { servicesManager, extensionManager, commandsManager } = ctx;
|
||||||
'@ohif/extension-cornerstone.utilityModule.tools'
|
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
|
const { toolNames } = utilityModule.exports;
|
||||||
initToolGroups(toolNames, Enums, toolGroupService, commandsManager);
|
|
||||||
|
|
||||||
const { unsubscribe } = toolGroupService.subscribe(
|
const { unsubscribe } = toolGroupService.subscribe(toolGroupService.EVENTS.VIEWPORT_ADDED, () => {
|
||||||
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
|
||||||
// For fusion toolGroup we need to add the volumeIds for the crosshairs
|
// when slabThickness is modified
|
||||||
// since in the fusion viewport we don't want both PT and CT to render MIP
|
const { displaySetMatchDetails } = hangingProtocolService.getMatchDetails();
|
||||||
// when slabThickness is modified
|
|
||||||
const { displaySetMatchDetails } = hangingProtocolService.getMatchDetails();
|
|
||||||
|
|
||||||
setCrosshairsConfiguration(
|
setCrosshairsConfiguration(
|
||||||
displaySetMatchDetails,
|
displaySetMatchDetails,
|
||||||
toolNames,
|
toolNames,
|
||||||
toolGroupService,
|
toolGroupService,
|
||||||
displaySetService
|
displaySetService
|
||||||
);
|
);
|
||||||
|
|
||||||
setFusionActiveVolume(
|
setFusionActiveVolume(displaySetMatchDetails, toolNames, toolGroupService, displaySetService);
|
||||||
displaySetMatchDetails,
|
});
|
||||||
toolNames,
|
|
||||||
toolGroupService,
|
|
||||||
displaySetService
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
unsubscriptions.push(unsubscribe);
|
this._unsubscriptions.push(unsubscribe);
|
||||||
toolbarService.register(toolbarButtons);
|
|
||||||
toolbarService.updateSection(toolbarService.sections.primary, [
|
|
||||||
'MeasurementTools',
|
|
||||||
'Zoom',
|
|
||||||
'Pan',
|
|
||||||
'WindowLevel',
|
|
||||||
'Crosshairs',
|
|
||||||
]);
|
|
||||||
|
|
||||||
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.topLeft, [
|
// Function-valued customization; kept out of the registered
|
||||||
'orientationMenu',
|
// `tmtvModeCustomizations` block because it needs the mode's
|
||||||
'dataOverlayMenu',
|
// commandsManager. Written at mode scope, so a global-scope customization
|
||||||
]);
|
// still overrides it by scope precedence.
|
||||||
|
customizationService.setCustomizations({
|
||||||
toolbarService.updateSection(toolbarService.sections.viewportActionMenu.bottomMiddle, [
|
'panelSegmentation.onSegmentationAdd': {
|
||||||
'AdvancedRenderingControls',
|
$set: () => {
|
||||||
]);
|
commandsManager.run('createNewLabelmapFromPT');
|
||||||
|
|
||||||
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],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
],
|
},
|
||||||
extensions: extensionDependencies,
|
});
|
||||||
hangingProtocol: tmtv.hangingProtocol,
|
|
||||||
sopClassHandlers: [ohif.sopClassHandler],
|
// For the hanging protocol we need to decide on the window level
|
||||||
...modeConfiguration,
|
// 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 = {
|
const mode = {
|
||||||
id,
|
id,
|
||||||
modeFactory,
|
modeFactory,
|
||||||
|
modeInstance,
|
||||||
extensionDependencies,
|
extensionDependencies,
|
||||||
|
customizations,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default mode;
|
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';
|
import { MIN_SEGMENTATION_DRAWING_RADIUS, MAX_SEGMENTATION_DRAWING_RADIUS } from './constants';
|
||||||
|
|
||||||
export const toolGroupIds = {
|
export { toolGroupIds };
|
||||||
CT: 'ctToolGroup',
|
|
||||||
PT: 'ptToolGroup',
|
|
||||||
Fusion: 'fusionToolGroup',
|
|
||||||
MIP: 'mipToolGroup',
|
|
||||||
default: 'default',
|
|
||||||
};
|
|
||||||
|
|
||||||
function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager) {
|
function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager) {
|
||||||
const tools = {
|
const tools = {
|
||||||
@ -189,7 +185,16 @@ function _initToolGroups(toolNames, Enums, toolGroupService, commandsManager) {
|
|||||||
toolGroupService.createToolGroupAndAddTools(toolGroupIds.MIP, mipTools);
|
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);
|
_initToolGroups(toolNames, Enums, toolGroupService, commandsManager);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,9 +28,9 @@ window.config = {
|
|||||||
// - mode: applied (Mode) on each mode enter — `*` (general) first,
|
// - mode: applied (Mode) on each mode enter — `*` (general) first,
|
||||||
// then a block keyed by the mode id / routeName.
|
// then a block keyed by the mode id / routeName.
|
||||||
customizationService: {
|
customizationService: {
|
||||||
// Pulls in platform/app/public/customizations/patientBirthDate.jsonc, which
|
// Pulls in platform/app/public/customizations/worklist/patientBirthDate.jsonc,
|
||||||
// adds a "Birth Date" column to the WorkList study list (global phase).
|
// which adds a "Birth Date" column to the WorkList study list (global phase).
|
||||||
requires: ['patientBirthDate'],
|
requires: ['worklist/patientBirthDate'],
|
||||||
|
|
||||||
// The previous (legacy-array) customizations, now in the explicit `global`
|
// The previous (legacy-array) customizations, now in the explicit `global`
|
||||||
// phase. A `global` block accepts the same input as setCustomizations: an
|
// 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):
|
// are never executed. Example (left disabled here on purpose):
|
||||||
//
|
//
|
||||||
// customizationUrlPrefixes: {
|
// customizationUrlPrefixes: {
|
||||||
// default: './customizations/', // ?customization=ctPresets
|
// default: './customizations/', // ?customization=tools/ctPresets
|
||||||
// '/remote/': 'https://cdn.example.com/ohif-custom/', // ?customization=/remote/siteA
|
// '/remote/': 'https://cdn.example.com/ohif-custom/', // ?customization=/remote/siteA
|
||||||
// },
|
// },
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|||||||
@ -3,23 +3,26 @@
|
|||||||
// Local development configuration.
|
// Local development configuration.
|
||||||
//
|
//
|
||||||
// This is the default config for the dev server (`pnpm run dev`, `dev:fast`,
|
// 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
|
// `start`). It is intentionally kept at parity with config/netlify.js (the
|
||||||
// enabled and the `?customization=` URL feature is ON via
|
// public demo deploy): every data source is enabled, the `?customization=` URL
|
||||||
// `customizationUrlPrefixes` — so the whole app can be exercised locally. The
|
// feature is ON via `customizationUrlPrefixes`, and the same startup
|
||||||
// locked-down config/default.js is what a plain production build emits instead.
|
// `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 = {
|
window.config = {
|
||||||
name: 'config/dev.js',
|
name: 'config/dev.js',
|
||||||
routerBasename: null,
|
routerBasename: null,
|
||||||
// whiteLabeling: {},
|
// whiteLabeling: {},
|
||||||
extensions: [],
|
extensions: [],
|
||||||
modes: [],
|
modes: [],
|
||||||
customizationService: {},
|
customizationService: ['@ohif/extension-default.customizationModule.theme'],
|
||||||
|
|
||||||
// URL-driven customizations (?customization=). The `default` prefix (no
|
// URL-driven customizations (?customization=). The `default` prefix (no
|
||||||
// slashes) is used for values without a leading slash; every other prefix
|
// 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
|
// 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.
|
// 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: {
|
customizationUrlPrefixes: {
|
||||||
default: './customizations/',
|
default: './customizations/',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -21,7 +21,7 @@ window.config = {
|
|||||||
extensions: [],
|
extensions: [],
|
||||||
modes: ['@ohif/mode-test'],
|
modes: ['@ohif/mode-test'],
|
||||||
// Allow the `default` prefix so e2e specs can exercise `?customization=` (e.g.
|
// 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: {
|
customizationUrlPrefixes: {
|
||||||
default: './customizations/',
|
default: './customizations/',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -17,7 +17,7 @@ window.config = {
|
|||||||
// slashes) is used for values without a leading slash; every other prefix
|
// 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
|
// 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.
|
// 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: {
|
customizationUrlPrefixes: {
|
||||||
default: './customizations/',
|
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
|
// site-specific set (key: `cornerstone.windowLevelPresets`). Other modalities
|
||||||
// keep their defaults because only the `CT` entry is overridden.
|
// keep their defaults because only the `CT` entry is overridden.
|
||||||
//
|
//
|
||||||
// Load it with `?customization=ctPresets`.
|
// Load it with `?customization=tools/ctPresets`.
|
||||||
{
|
{
|
||||||
"global": {
|
"global": {
|
||||||
"cornerstone.windowLevelPresets": {
|
"cornerstone.windowLevelPresets": {
|
||||||
@ -4,7 +4,7 @@
|
|||||||
// is created (key: `measurementLabels`). `labelOnMeasure` triggers the prompt on
|
// is created (key: `measurementLabels`). `labelOnMeasure` triggers the prompt on
|
||||||
// creation and `exclusive` restricts entries to the provided items.
|
// creation and `exclusive` restricts entries to the provided items.
|
||||||
//
|
//
|
||||||
// Load it with `?customization=measurementLabels`.
|
// Load it with `?customization=tools/measurementLabels`.
|
||||||
{
|
{
|
||||||
"global": {
|
"global": {
|
||||||
"measurementLabels": {
|
"measurementLabels": {
|
||||||
@ -8,7 +8,7 @@
|
|||||||
// extension (`cornerstone.toolbarButtons` / `cornerstone.toolbarSections`) using
|
// extension (`cornerstone.toolbarButtons` / `cornerstone.toolbarSections`) using
|
||||||
// immutability-helper `$push` commands, so the built-in buttons are preserved.
|
// immutability-helper `$push` commands, so the built-in buttons are preserved.
|
||||||
//
|
//
|
||||||
// Load it with `?customization=smoothRotate`.
|
// Load it with `?customization=tools/smoothRotate`.
|
||||||
{
|
{
|
||||||
"global": {
|
"global": {
|
||||||
"cornerstone.toolbarButtons": {
|
"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
|
// Demonstrates a runtime-loaded customization that overrides the default
|
||||||
// viewport overlay with a veterinary-style demographics layout. Loaded via
|
// 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
|
// Uses the same `global`-at-top-level shape as the cornerstone overlay samples
|
||||||
// and `inheritsFrom: 'ohif.overlayItem'` on each row, matching
|
// and `inheritsFrom: 'ohif.overlayItem'` on each row, matching
|
||||||
@ -39,14 +39,14 @@
|
|||||||
{
|
{
|
||||||
"id": "PatientSpecies",
|
"id": "PatientSpecies",
|
||||||
"inheritsFrom": "ohif.overlayItem",
|
"inheritsFrom": "ohif.overlayItem",
|
||||||
"attribute": "PatientSpecies",
|
"attribute": "PatientSpeciesDescription",
|
||||||
"label": "Species",
|
"label": "Species",
|
||||||
"title": "Patient species"
|
"title": "Patient species"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "PatientBreed",
|
"id": "PatientBreed",
|
||||||
"inheritsFrom": "ohif.overlayItem",
|
"inheritsFrom": "ohif.overlayItem",
|
||||||
"attribute": "PatientBreed",
|
"attribute": "PatientBreedDescription",
|
||||||
"label": "Breed",
|
"label": "Breed",
|
||||||
"title": "Patient breed"
|
"title": "Patient breed"
|
||||||
}
|
}
|
||||||
@ -8,8 +8,8 @@
|
|||||||
// tag 00100030).
|
// tag 00100030).
|
||||||
//
|
//
|
||||||
// This applies in the `global` phase, so it is in place before the WorkList
|
// 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
|
// renders. Load it with `?customization=worklist/patientBirthDate`, or pull it in
|
||||||
// `appConfig.customizationService.requires`.
|
// from `appConfig.customizationService.requires`.
|
||||||
{
|
{
|
||||||
"global": {
|
"global": {
|
||||||
"workList.columns": {
|
"workList.columns": {
|
||||||
@ -93,13 +93,31 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
|
|||||||
const loadedExtensions = await loadModules([...defaultExtensions, ...appConfig.extensions]);
|
const loadedExtensions = await loadModules([...defaultExtensions, ...appConfig.extensions]);
|
||||||
|
|
||||||
const { customizationService } = servicesManager.services;
|
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
|
// Resolve every customization module up front — from
|
||||||
// `appConfig.customizationService.requires` and the `?customization=` URL
|
// `appConfig.customizationService.requires` and the `?customization=` URL
|
||||||
// parameter — long before any mode loads, then apply the `bootstrap` phase
|
// parameter — then apply the `bootstrap` phase BEFORE extensions register so
|
||||||
// BEFORE extensions register so it is in place while they initialize. Modules
|
// it is in place while they initialize. Modules are only loaded when
|
||||||
// are only loaded when `appConfig.customizationUrlPrefixes` allows their
|
// `appConfig.customizationUrlPrefixes` allows their prefix; the feature is
|
||||||
// prefix; the feature is off by default, and a value with an unconfigured
|
// off by default, and a value with an unconfigured prefix throws here
|
||||||
// prefix throws here (aborting startup) rather than being silently ignored.
|
// (aborting startup) rather than being silently ignored.
|
||||||
await customizationService.loadAndApplyBootstrapCustomizations(extensionManager);
|
await customizationService.loadAndApplyBootstrapCustomizations(extensionManager);
|
||||||
|
|
||||||
await extensionManager.registerExtensions(loadedExtensions, appConfig.dataSources);
|
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 webWorkers at app level
|
||||||
// TODO: We no longer init the user Manager
|
// 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
|
// This is the name for the loaded instance object
|
||||||
appConfig.loadedModes = [];
|
appConfig.loadedModes = [];
|
||||||
const modesById = new Set();
|
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) {
|
if (isMounted.current) {
|
||||||
setExtensionDependenciesLoaded(true);
|
setExtensionDependenciesLoaded(true);
|
||||||
}
|
}
|
||||||
@ -180,10 +189,19 @@ export default function ModeRoute({
|
|||||||
if (isMounted.current) {
|
if (isMounted.current) {
|
||||||
const { leftPanels = [], rightPanels = [], ...layoutProps } = layoutData.props;
|
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.reset();
|
||||||
panelService.addPanels(panelService.PanelPosition.Left, leftPanels);
|
panelService.addPanels(panelService.PanelPosition.Left, leftPanels);
|
||||||
panelService.addPanels(panelService.PanelPosition.Right, rightPanels);
|
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
|
// layoutProps contains all props but leftPanels and rightPanels
|
||||||
layoutData.props = layoutProps;
|
layoutData.props = layoutProps;
|
||||||
|
|
||||||
@ -222,11 +240,55 @@ export default function ModeRoute({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// `extensionManager.onModeEnter` resets the customization mode scope via
|
// `extensionManager.onModeEnter` resets the customization mode scope via
|
||||||
// `customizationService.onModeEnter`; now layer on the `mode` phase blocks
|
// `customizationService.onModeEnter`; the mode scope is then layered
|
||||||
// for this mode — the general (`*`) block first, then any block keyed by
|
// bottom-up so the final value of every key is decided by scope
|
||||||
// this mode's id / routeName so a single mode can override the general one.
|
// 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]);
|
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
|
// use the URL hangingProtocolId if it exists, otherwise use the one
|
||||||
// defined in the mode configuration
|
// defined in the mode configuration
|
||||||
const hangingProtocolIdToUse = hangingProtocolService.getProtocolById(
|
const hangingProtocolIdToUse = hangingProtocolService.getProtocolById(
|
||||||
|
|||||||
@ -13,6 +13,14 @@ describe('preserveQueryParameters', () => {
|
|||||||
expect(out.getAll('configUrl')).toEqual(['foo.js']);
|
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', () => {
|
it('preserves all repeated values for the customization key', () => {
|
||||||
const current = new URLSearchParams();
|
const current = new URLSearchParams();
|
||||||
current.append('customization', 'a');
|
current.append('customization', 'a');
|
||||||
|
|||||||
@ -12,6 +12,7 @@ export const preserveKeys = [
|
|||||||
'screenNumber',
|
'screenNumber',
|
||||||
'hangingProtocolId',
|
'hangingProtocolId',
|
||||||
'customization',
|
'customization',
|
||||||
|
'theme',
|
||||||
];
|
];
|
||||||
|
|
||||||
function preserveKey(query: URLSearchParams, current: URLSearchParams, key: string) {
|
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.globalCustomizations.get(customizationId) ??
|
||||||
this.modeCustomizations.get(customizationId) ??
|
this.modeCustomizations.get(customizationId) ??
|
||||||
this.defaultCustomizations.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) {
|
if (newTransformed !== undefined) {
|
||||||
this.transformedCustomizations.set(customizationId, newTransformed);
|
this.transformedCustomizations.set(customizationId, newTransformed);
|
||||||
}
|
}
|
||||||
return 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.
|
* Returns a customization value, or the provided fallback when unset.
|
||||||
*/
|
*/
|
||||||
@ -1112,6 +1197,15 @@ export function normalizeCustomizationConfig(configuration: unknown): {
|
|||||||
return {};
|
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) {
|
function hasDollarKey(value) {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
for (const item of value) {
|
for (const item of value) {
|
||||||
@ -1128,7 +1222,11 @@ function hasDollarKey(value) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
for (const key of Object.keys(value)) {
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
if (hasDollarKey(value[key])) {
|
if (hasDollarKey(value[key])) {
|
||||||
|
|||||||
@ -26,7 +26,7 @@ allowlist.
|
|||||||
window.config = {
|
window.config = {
|
||||||
customizationUrlPrefixes: {
|
customizationUrlPrefixes: {
|
||||||
// The `default` prefix (no slashes) handles values with no leading slash.
|
// 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
|
// Every other prefix MUST start and end with a slash and is matched against
|
||||||
// the leading `/segment/` of the value.
|
// the leading `/segment/` of the value.
|
||||||
'/remote/': 'https://cdn.example.com/ohif-customizations/', // ?customization=/remote/siteA
|
'/remote/': 'https://cdn.example.com/ohif-customizations/', // ?customization=/remote/siteA
|
||||||
@ -36,7 +36,7 @@ window.config = {
|
|||||||
|
|
||||||
Resolution rules:
|
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`
|
- `?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
|
- **A value whose prefix is not configured throws and aborts startup** rather than
|
||||||
being silently ignored. With no `customizationUrlPrefixes` configured, *any*
|
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.
|
// in the global config, which is not itself updatable by any customization.
|
||||||
customizationUrlPrefixes: { default: './customizations/' },
|
customizationUrlPrefixes: { default: './customizations/' },
|
||||||
customizationService: {
|
customizationService: {
|
||||||
requires: ['patientBirthDate'], // resolves ./customizations/patientBirthDate.jsonc
|
requires: ['worklist/patientBirthDate'], // resolves ./customizations/worklist/patientBirthDate.jsonc
|
||||||
global: [ // mixes string references and inline maps
|
global: [ // mixes string references and inline maps
|
||||||
'@ohif/extension-default.customizationModule.datasources',
|
'@ohif/extension-default.customizationModule.datasources',
|
||||||
{ 'workList.variant': 'default' },
|
{ '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`
|
## 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
|
- **`config/dev.js`** (new) is the full-featured local-development config: every
|
||||||
data source enabled and `?customization=` turned on. The dev-server scripts
|
data source enabled and `?customization=` turned on. The dev-server scripts
|
||||||
(`pnpm run dev`, `dev:fast`, `start`) now default to `config/dev.js`.
|
(`pnpm run dev`, `dev:fast`, `start`) now default to `config/dev.js`. It is
|
||||||
- **`config/netlify.js`** is the public demo / Netlify deploy config: the full
|
kept at **parity with `config/netlify.js`** — including the startup
|
||||||
data source set plus `customizationUrlPrefixes: { default: './customizations/' }`.
|
`customizationService` modules (e.g. the appearance/theme customization) — so
|
||||||
- **`config/default.js`** remains the fallback for a real production build
|
customizations behave locally exactly as they do on the public demo.
|
||||||
(`pnpm run build` with no `APP_CONFIG`).
|
- **`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
|
### `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>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left">
|
<td align="left">
|
||||||
enableSegmentationEdit
|
excludedModalities
|
||||||
</td>
|
</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>
|
||||||
<tr>
|
<tr>
|
||||||
<td align="left">
|
<td align="left">
|
||||||
toolbarSections
|
toolbarSections
|
||||||
</td>
|
</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>
|
</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,
|
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.
|
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
|
### Consuming Extensions
|
||||||
|
|
||||||
As mentioned in the [Extensions](../extensions/index.md) section, in `OHIF-v3`
|
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
|
`window.config`'s `customizationService` entries, but loaded at runtime from `?customization=`. Any
|
||||||
customization key that is read through `customizationService.getCustomization(...)` can therefore be
|
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/`
|
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
|
(the `default` prefix) and load it with `?customization=<path>`. The shipped examples are grouped into
|
||||||
can keep `//` comments and trailing commas in them.
|
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` /
|
demonstrates a fourth scenario — replacing the viewport overlay layout via `viewportOverlay.topLeft` /
|
||||||
`viewportOverlay.topRight`.
|
`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.
|
`$merge` replaces only the `CT` entry, so presets for other modalities (PT, etc.) are kept.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
// platform/app/public/customizations/ctPresets.jsonc -> ?customization=ctPresets
|
// platform/app/public/customizations/tools/ctPresets.jsonc -> ?customization=tools/ctPresets
|
||||||
{
|
{
|
||||||
"global": {
|
"global": {
|
||||||
"cornerstone.windowLevelPresets": {
|
"cornerstone.windowLevelPresets": {
|
||||||
@ -143,7 +146,7 @@ Make the viewer prompt for a label from a fixed list whenever a measurement is c
|
|||||||
(key: `measurementLabels`).
|
(key: `measurementLabels`).
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
// platform/app/public/customizations/measurementLabels.jsonc -> ?customization=measurementLabels
|
// platform/app/public/customizations/tools/measurementLabels.jsonc -> ?customization=tools/measurementLabels
|
||||||
{
|
{
|
||||||
"global": {
|
"global": {
|
||||||
"measurementLabels": {
|
"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
|
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.
|
`$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`
|
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*):
|
tool (drag to rotate the image freely, unlike the fixed 90° *Rotate Right*):
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
// platform/app/public/customizations/smoothRotate.jsonc -> ?customization=smoothRotate
|
// platform/app/public/customizations/tools/smoothRotate.jsonc -> ?customization=tools/smoothRotate
|
||||||
{
|
{
|
||||||
"global": {
|
"global": {
|
||||||
"cornerstone.toolbarButtons": {
|
"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
|
> 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.
|
> 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)
|
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
|
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.
|
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)
|
### URL modules, bootstrap, and client-side navigation (intended behavior)
|
||||||
|
|
||||||
Modules referenced from `?customization=` are loaded when the app applies URL customizations from
|
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':
|
'@ohif/extension-test':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../extensions/test-extension
|
version: link:../../extensions/test-extension
|
||||||
|
'@ohif/mode-basic':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../basic
|
||||||
i18next:
|
i18next:
|
||||||
specifier: 17.3.1
|
specifier: 17.3.1
|
||||||
version: 17.3.1
|
version: 17.3.1
|
||||||
@ -1254,6 +1257,12 @@ importers:
|
|||||||
'@ohif/extension-measurement-tracking':
|
'@ohif/extension-measurement-tracking':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../extensions/measurement-tracking
|
version: link:../../extensions/measurement-tracking
|
||||||
|
'@ohif/extension-tmtv':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../extensions/tmtv
|
||||||
|
'@ohif/mode-basic':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../basic
|
||||||
i18next:
|
i18next:
|
||||||
specifier: 17.3.1
|
specifier: 17.3.1
|
||||||
version: 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';
|
const studyInstanceUID = '2.25.96975534054447904995905761963464388233';
|
||||||
|
|
||||||
await visitStudyOptions(page, studyInstanceUID, {
|
await visitStudyOptions(page, studyInstanceUID, {
|
||||||
customization: 'veterinaryOverlay',
|
customization: 'veterinary/veterinaryOverlay',
|
||||||
});
|
});
|
||||||
|
|
||||||
const patientNameOverlayItem = page
|
const patientNameOverlayItem = page
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user