feat!: Ability to configure cornerstone tools via extension configuration (#1229)

* Fix ExtensionManager bug and add test to bandaid

* Add tools configuration to extension manager preinit

* Fix reducing of configs

* Merge internal with external configs

* Merge internal with external configs

* Remove dialog from init in measurementstable

* Testing injected configuration

* New way to set config

* Add new prop to dialog provider to allow disabling last position

* Remove code from preinit in cornerstone

* Add new prop to dialog provider to allow disabling last position

* Add centralize to dialogs

* Reorder dialogs when adding them

* Fix draggable styles (cursor)

* Remove repositioning methods from labelling flow and remove overlay from labelling manager

* Fix empty array being set in bringToFront

* Add new command to update table and pass commands manager to modules/preinit hook

* Ad UIContextMenu service / factory

* Use new contextmenu service in measurementspanel extension

* Use dialogs for arrow annotate in default

* Remove positioning funcionality from tool context menu

* Add context menu service

* Pass commandsModule to extension

* Update edit description dialog and simple dialog to position relative

* Remove style code from labelling flow and manager

* Remove eventdata from labelling

* Remove labelling code from measurement init

* Add commandsmanager to provider

* Update contextmenu provider and service

* Add touchstart and mouseclick to hide contextmenu

* Hide labelling if click/touch

* Remove labelling and context menu dead code

* Fix undefined bug if ViewerMain grid has no children

* Fix broken prop on context menu

* Update commandsmodule based on master

* Fix broken configuration

* Update script tag config

* Remove cornerstone from toolcontextmenu

* Remove cornerstone from toolcontextmenu

* Split labelling and context menu providers

* Split labelling and context menu providers

* Update test

* Destructure extensions into new array

* CR Update: Move default arrow config to cornerstone instead of default

* CR Update: Fix app configuration props structure

* CR Update: Fix app configuration prop in script tag and extract commands manager from providers

* CR Update: Create custom providers to use commandsManager

* CR Update: Use services directly in measurementspanel

* CR Update: Pass components to providers

* CR Update: Remove position from dialog

* CR Update: fix dialog prop check

* CR Update: Fix comments

* CR Update: Update documentation

* CR Update: Add test default configuration

* CR Update: Add default empy array to extensions

* CR Update: Update i18n configuration all ot match current function configuration

* CR Update: Add defaults to injected dependencies in configuration and extension configuration

* CR Update: Add defaults to configuration with no args

* Update documentation

* CR Update: Add default for tools

* CR Update: Update config object to i18n

* CR Update: spread defaults

* CR Update: Add tool configuration example to cornerstone extension

* CR Update: Add tool configuration to netlify (testing)

* CR Update: Remove netlify config for tools

* CR Update: Rollback changes to i18n to be fixed later

* CR Update: Update documentation and pass whole cornerstone config object instead of tools key

SEE: https://www.conventionalcommits.org/en/v1.0.0/#commit-message-with-both-and-breaking-change-footer

BREAKING CHANGE: modifies the exposed react <App /> components props. The contract for providing configuration for the app has changed. Please reference updated documentation for guidance.
This commit is contained in:
Igor Octaviano 2019-12-09 14:47:23 -03:00 committed by Danny Brown
parent fd7620ced9
commit 55a580659e
66 changed files with 1464 additions and 1223 deletions

View File

@ -66,6 +66,52 @@ window.config = {
}; };
``` ```
The configuration can also be written as a JS Function in case you need to inject dependencies like external services:
```js
window.config = ({ servicesManager } = {}) => {
const { UIDialogService } = servicesManager.services;
return {
cornerstoneExtensionConfig: {
tools: {
ArrowAnnotate: {
configuration: {
getTextCallback: (callback, eventDetails) => UIDialogService.create({...
}
}
},
},
routerBasename: '/',
servers: {
dicomWeb: [
{
name: 'DCM4CHEE',
wadoUriRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/wado',
qidoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs',
wadoRoot: 'https://server.dcmjs.org/dcm4chee-arc/aets/DCM4CHEE/rs',
qidoSupportsIncludeField: true,
imageRendering: 'wadors',
thumbnailRendering: 'wadors',
},
],
},
};
};
```
You can also create a new config file and specify its path relative to the build
output's root by setting the `APP_CONFIG` environment variable. You can set the
value of this environment variable a few different ways:
- ~[Add a temporary environment variable in your shell](https://facebook.github.io/create-react-app/docs/adding-custom-environment-variables#adding-temporary-environment-variables-in-your-shell)~
- Previous `react-scripts` functionality that we need to duplicate with
`dotenv-webpack`
- ~[Add environment specific variables in `.env` file(s)](https://facebook.github.io/create-react-app/docs/adding-custom-environment-variables#adding-development-environment-variables-in-env)~
- Previous `react-scripts` functionality that we need to duplicate with
`dotenv-webpack`
- Using the `cross-env` package in an npm script:
- `"build": "cross-env APP_CONFIG=config/my-config.js react-scripts build"`
After updating the configuration, `yarn run build` to generate updated build After updating the configuration, `yarn run build` to generate updated build
output. output.

View File

@ -24,12 +24,12 @@ include tags. Here's how it works:
</ul> </ul>
<ol start="2"> <ol start="2">
<li>Create a JS Object to hold the OHIF Viewer's configuration. Here are some <li>Create a JS Object or Function to hold the OHIF Viewer's configuration. Here are some
example values that would allow the viewer to hit our public PACS:</li> example values that would allow the viewer to hit our public PACS:</li>
</ol> </ol>
```js ```js
// Set before importing `ohif-viewer` // Set before importing `ohif-viewer` (JS Object)
window.config = { window.config = {
// default: '/' // default: '/'
routerBasename: '/', routerBasename: '/',
@ -49,6 +49,9 @@ window.config = {
}; };
``` ```
To learn more about how you can configure the OHIF Viewer, check out our
[Configuration Guide](./index.md).
<ol start="3"><li> <ol start="3"><li>
Render the viewer in the web page's target <code>div</code> Render the viewer in the web page's target <code>div</code>
</li></ol> </li></ol>

View File

@ -12,8 +12,9 @@ export default {
*/ */
preRegistration({ preRegistration({
servicesManager, servicesManager = {},
configuration: extensionConfiguration, commandsManager = {},
configuration = {},
}) {}, }) {},
/** /**

View File

@ -67,6 +67,24 @@ Our Viewport wraps [cornerstonejs/react-cornerstone-viewport][react-viewport]
and is connected the redux store. This module is the most prone to change as we and is connected the redux store. This module is the most prone to change as we
hammer out our Viewport interface. hammer out our Viewport interface.
## Tool Configuration
Tools can be configured through extension configuration using the tools key:
```js
...
cornerstoneExtensionConfig: {
tools: {
ArrowAnnotate: {
configuration: {
getTextCallback: (callback, eventDetails) => callback(prompt('Enter your custom annotation')),
},
},
},
},
...
```
## Resources ## Resources
### Repositories ### Repositories

View File

@ -47,6 +47,7 @@
"dependencies": { "dependencies": {
"@babel/runtime": "^7.5.5", "@babel/runtime": "^7.5.5",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"lodash.merge": "^4.6.2",
"lodash.throttle": "^4.1.1", "lodash.throttle": "^4.1.1",
"query-string": "^6.8.3", "query-string": "^6.8.3",
"react-cornerstone-viewport": "2.x.x" "react-cornerstone-viewport": "2.x.x"

View File

@ -248,10 +248,13 @@ class OHIFCornerstoneViewport extends Component {
// TODO: Does it make more sense to use Context? // TODO: Does it make more sense to use Context?
if (this.props.children && this.props.children.length) { if (this.props.children && this.props.children.length) {
childrenWithProps = this.props.children.map((child, index) => { childrenWithProps = this.props.children.map((child, index) => {
return React.cloneElement(child, { return (
viewportIndex: this.props.viewportIndex, child &&
key: index, React.cloneElement(child, {
}); viewportIndex: this.props.viewportIndex,
key: index,
})
);
}); });
} }

View File

@ -148,18 +148,118 @@ const commandsModule = ({ servicesManager }) => {
showDownloadViewportModal: ({ title, viewports }) => { showDownloadViewportModal: ({ title, viewports }) => {
const activeViewportIndex = viewports.activeViewportIndex; const activeViewportIndex = viewports.activeViewportIndex;
const { UIModalService } = servicesManager.services; const { UIModalService } = servicesManager.services;
UIModalService.show({ if (UIModalService) {
content: CornerstoneViewportDownloadForm, UIModalService.show({
title, content: CornerstoneViewportDownloadForm,
contentProps: { title,
activeViewportIndex, contentProps: {
onClose: UIModalService.hide, activeViewportIndex,
}, onClose: UIModalService.hide,
},
});
}
},
updateTableWithNewMeasurementData({
toolType,
measurementNumber,
location,
description,
}) {
// Update all measurements by measurement number
const measurementApi = OHIF.measurements.MeasurementApi.Instance;
const measurements = measurementApi.tools[toolType].filter(
m => m.measurementNumber === measurementNumber
);
measurements.forEach(measurement => {
measurement.location = location;
measurement.description = description;
measurementApi.updateMeasurement(measurement.toolType, measurement);
}); });
measurementApi.syncMeasurementsAndToolData();
// Update images in all active viewports
cornerstone.getEnabledElements().forEach(enabledElement => {
cornerstone.updateImage(enabledElement.element);
});
},
getNearbyToolData({ element, canvasCoordinates, availableToolTypes }) {
const nearbyTool = {};
let pointNearTool = false;
availableToolTypes.forEach(toolType => {
const elementToolData = cornerstoneTools.getToolState(
element,
toolType
);
if (!elementToolData) {
return;
}
elementToolData.data.forEach((toolData, index) => {
let elementToolInstance = cornerstoneTools.getToolForElement(
element,
toolType
);
if (!elementToolInstance) {
elementToolInstance = cornerstoneTools.getToolForElement(
element,
`${toolType}Tool`
);
}
if (!elementToolInstance) {
console.warn('Tool not found.');
return undefined;
}
if (
elementToolInstance.pointNearTool(
element,
toolData,
canvasCoordinates
)
) {
pointNearTool = true;
nearbyTool.tool = toolData;
nearbyTool.index = index;
nearbyTool.toolType = toolType;
}
});
if (pointNearTool) {
return false;
}
});
return pointNearTool ? nearbyTool : undefined;
},
removeToolState: ({ element, toolType, tool }) => {
cornerstoneTools.removeToolState(element, toolType, tool);
cornerstone.updateImage(element);
}, },
}; };
const definitions = { const definitions = {
getNearbyToolData: {
commandFn: actions.getNearbyToolData,
storeContexts: [],
options: {},
},
removeToolState: {
commandFn: actions.removeToolState,
storeContexts: [],
options: {},
},
updateTableWithNewMeasurementData: {
commandFn: actions.updateTableWithNewMeasurementData,
storeContexts: [],
options: {},
},
showDownloadViewportModal: { showDownloadViewportModal: {
commandFn: actions.showDownloadViewportModal, commandFn: actions.showDownloadViewportModal,
storeContexts: ['viewports'], storeContexts: ['viewports'],

View File

@ -4,6 +4,7 @@ import csTools from 'cornerstone-tools';
import initCornerstoneTools from './initCornerstoneTools.js'; import initCornerstoneTools from './initCornerstoneTools.js';
import queryString from 'query-string'; import queryString from 'query-string';
import { SimpleDialog } from '@ohif/ui'; import { SimpleDialog } from '@ohif/ui';
import merge from 'lodash.merge';
function fallbackMetaDataProvider(type, imageId) { function fallbackMetaDataProvider(type, imageId) {
if (!imageId.includes('wado?requestType=WADO')) { if (!imageId.includes('wado?requestType=WADO')) {
@ -26,30 +27,33 @@ cornerstone.metaData.addProvider(fallbackMetaDataProvider, -1);
/** /**
* *
* @param {object} configuration * @param {Object} servicesManager
* @param {Object} configuration
* @param {Object|Array} configuration.csToolsConfig * @param {Object|Array} configuration.csToolsConfig
*/ */
export default function init({ servicesManager, configuration = {} }) { export default function init({ servicesManager, configuration }) {
const { UIDialogService } = servicesManager.services;
const callInputDialog = (data, event, callback) => { const callInputDialog = (data, event, callback) => {
let dialogId = UIDialogService.create({ const { UIDialogService } = servicesManager.services;
content: SimpleDialog.InputDialog,
defaultPosition: { if (UIDialogService) {
x: (event && event.currentPoints.canvas.x) || 0, let dialogId = UIDialogService.create({
y: (event && event.currentPoints.canvas.y) || 0, centralize: true,
}, isDraggable: false,
showOverlay: true, content: SimpleDialog.InputDialog,
contentProps: { useLastPosition: false,
title: 'Enter your annotation', showOverlay: true,
label: 'New label', contentProps: {
measurementData: data ? { description: data.text } : {}, title: 'Enter your annotation',
onClose: () => UIDialogService.dismiss({ id: dialogId }), label: 'New label',
onSubmit: value => { measurementData: data ? { description: data.text } : {},
callback(value); onClose: () => UIDialogService.dismiss({ id: dialogId }),
UIDialogService.dismiss({ id: dialogId }); onSubmit: value => {
callback(value);
UIDialogService.dismiss({ id: dialogId });
},
}, },
}, });
}); }
}; };
const { csToolsConfig } = configuration; const { csToolsConfig } = configuration;
@ -73,61 +77,55 @@ export default function init({ servicesManager, configuration = {} }) {
initCornerstoneTools(defaultCsToolsConfig); initCornerstoneTools(defaultCsToolsConfig);
// ~~ Toooools 🙌 // ~~ Toooools 🙌
const {
PanTool,
ZoomTool,
WwwcTool,
MagnifyTool,
StackScrollTool,
StackScrollMouseWheelTool,
// Touch
PanMultiTouchTool,
ZoomTouchPinchTool,
// Annotations
EraserTool,
BidirectionalTool,
LengthTool,
AngleTool,
FreehandRoiTool,
EllipticalRoiTool,
DragProbeTool,
RectangleRoiTool,
// Segmentation
BrushTool,
} = csTools;
const tools = [ const tools = [
PanTool, csTools.PanTool,
ZoomTool, csTools.ZoomTool,
WwwcTool, csTools.WwwcTool,
MagnifyTool, csTools.MagnifyTool,
StackScrollTool, csTools.StackScrollTool,
StackScrollMouseWheelTool, csTools.StackScrollMouseWheelTool,
// Touch // Touch
PanMultiTouchTool, csTools.PanMultiTouchTool,
ZoomTouchPinchTool, csTools.ZoomTouchPinchTool,
// Annotations // Annotations
EraserTool, csTools.ArrowAnnotateTool,
BidirectionalTool, csTools.EraserTool,
LengthTool, csTools.BidirectionalTool,
AngleTool, csTools.LengthTool,
FreehandRoiTool, csTools.AngleTool,
EllipticalRoiTool, csTools.FreehandRoiTool,
DragProbeTool, csTools.EllipticalRoiTool,
RectangleRoiTool, csTools.DragProbeTool,
csTools.RectangleRoiTool,
// Segmentation // Segmentation
BrushTool, csTools.BrushTool,
]; ];
tools.forEach(tool => csTools.addTool(tool)); /* Add extension tools configuration here. */
const extensionToolsConfiguration = {
csTools.addTool(csTools.ArrowAnnotateTool, { ArrowAnnotate: {
configuration: { configuration: {
getTextCallback: (callback, eventDetails) => getTextCallback: (callback, eventDetails) =>
callInputDialog(null, eventDetails, callback), callInputDialog(null, eventDetails, callback),
changeTextCallback: (data, eventDetails, callback) => changeTextCallback: (data, eventDetails, callback) =>
callInputDialog(data, eventDetails, callback), callInputDialog(data, eventDetails, callback),
},
}, },
}); };
const isEmpty = obj => Object.keys(obj).length < 1;
if (!isEmpty(configuration.tools) || !isEmpty(extensionToolsConfiguration)) {
/* Add tools with its custom props through extension configuration. */
tools.forEach(tool => {
const toolName = tool.name.replace('Tool', '');
const configurationToolProps = configuration.tools[toolName] || {};
const extensionToolProps = extensionToolsConfiguration[toolName];
let props = merge(extensionToolProps, configurationToolProps);
csTools.addTool(tool, props);
});
} else {
tools.forEach(tool => csTools.addTool(tool));
}
csTools.setToolActive('Pan', { mouseButtonMask: 4 }); csTools.setToolActive('Pan', { mouseButtonMask: 4 });
csTools.setToolActive('Zoom', { mouseButtonMask: 2 }); csTools.setToolActive('Zoom', { mouseButtonMask: 2 });

View File

@ -358,10 +358,13 @@ class OHIFVTKViewport extends Component {
// TODO: Does it make more sense to use Context? // TODO: Does it make more sense to use Context?
if (this.props.children && this.props.children.length) { if (this.props.children && this.props.children.length) {
childrenWithProps = this.props.children.map((child, index) => { childrenWithProps = this.props.children.map((child, index) => {
return React.cloneElement(child, { return (
viewportIndex: this.props.viewportIndex, child &&
key: index, React.cloneElement(child, {
}); viewportIndex: this.props.viewportIndex,
key: index,
})
);
}); });
} }

View File

@ -59,7 +59,7 @@ export class CommandsManager {
* *
* @method * @method
* @param {string} contextName - Namespace for commands * @param {string} contextName - Namespace for commands
* @returs {Object} - the matched context * @returns {Object} - the matched context
*/ */
getContext(contextName) { getContext(contextName) {
const context = this.contexts[contextName]; const context = this.contexts[contextName];

View File

@ -26,7 +26,7 @@ export default class ExtensionManager {
const hasConfiguration = Array.isArray(extension); const hasConfiguration = Array.isArray(extension);
if (hasConfiguration) { if (hasConfiguration) {
const [ohifExtension, configuration] = extensions; const [ohifExtension, configuration] = extension;
this.registerExtension(ohifExtension, configuration); this.registerExtension(ohifExtension, configuration);
} else { } else {
this.registerExtension(extension); this.registerExtension(extension);

View File

@ -37,6 +37,24 @@ describe('ExtensionManager.js', () => {
// Assert // Assert
expect(extensionManager.registerExtension.mock.calls.length).toBe(3); expect(extensionManager.registerExtension.mock.calls.length).toBe(3);
}); });
it('calls registerExtension() for each extension passing its configuration if tuple', () => {
const fakeConfiguration = { testing: true };
extensionManager.registerExtension = jest.fn();
// SUT
const fakeExtensions = [
{ one: '1' },
[{ two: '2' }, fakeConfiguration],
{ three: '3 ' },
];
extensionManager.registerExtensions(fakeExtensions);
// Assert
expect(extensionManager.registerExtension.mock.calls[1]).toContain(
fakeConfiguration
);
});
}); });
describe('registerExtension()', () => { describe('registerExtension()', () => {

View File

@ -23,6 +23,8 @@ import {
createUINotificationService, createUINotificationService,
createUIModalService, createUIModalService,
createUIDialogService, createUIDialogService,
createUIContextMenuService,
createUILabellingFlowService,
} from './services'; } from './services';
const OHIF = { const OHIF = {
@ -53,6 +55,8 @@ const OHIF = {
createUINotificationService, createUINotificationService,
createUIModalService, createUIModalService,
createUIDialogService, createUIDialogService,
createUIContextMenuService,
createUILabellingFlowService,
}; };
export { export {
@ -82,6 +86,8 @@ export {
createUINotificationService, createUINotificationService,
createUIModalService, createUIModalService,
createUIDialogService, createUIDialogService,
createUIContextMenuService,
createUILabellingFlowService,
}; };
export { OHIF }; export { OHIF };

View File

@ -13,6 +13,8 @@ describe('Top level exports', () => {
'createUINotificationService', 'createUINotificationService',
'createUIModalService', 'createUIModalService',
'createUIDialogService', 'createUIDialogService',
'createUIContextMenuService',
'createUILabellingFlowService',
// //
'utils', 'utils',
'studies', 'studies',

View File

@ -0,0 +1,63 @@
/**
* UI Context Menu
*
* @typedef {Object} ContextMenuProps
* @property {Event} event The event with tool information.
*/
const uiContextMenuServicePublicAPI = {
name: 'UIContextMenuService',
hide,
show,
setServiceImplementation,
};
const uiContextMenuServiceImplementation = {
_show: () => console.warn('show() NOT IMPLEMENTED'),
_hide: () => console.warn('hide() NOT IMPLEMENTED'),
};
function createUIContextMenuService() {
return uiContextMenuServicePublicAPI;
}
/**
* Show a new UI ContextMenu dialog;
*
* @param {ContextMenuProps} props { event }
*/
function show({ event }) {
return uiContextMenuServiceImplementation._show({
event,
});
}
/**
* Hide a UI ContextMenu dialog;
*
*/
function hide() {
return uiContextMenuServiceImplementation._hide();
}
/**
*
*
* @param {*} {
* show: showImplementation,
* hide: hideImplementation,
* }
*/
function setServiceImplementation({
show: showImplementation,
hide: hideImplementation,
}) {
if (showImplementation) {
uiContextMenuServiceImplementation._show = showImplementation;
}
if (hideImplementation) {
uiContextMenuServiceImplementation._hide = hideImplementation;
}
}
export default createUIContextMenuService;

View File

@ -17,8 +17,9 @@
* @property {Object} contentProps The dialog content props. * @property {Object} contentProps The dialog content props.
* @property {boolean} [isDraggable=true] Controls if dialog content is draggable or not. * @property {boolean} [isDraggable=true] Controls if dialog content is draggable or not.
* @property {boolean} [showOverlay=false] Controls dialog overlay. * @property {boolean} [showOverlay=false] Controls dialog overlay.
* @property {boolean} [centralize=false] Center the dialog on the screen.
* @property {boolean} [preservePosition=true] Use last position instead of default.
* @property {ElementPosition} defaultPosition Specifies the `x` and `y` that the dragged item should start at. * @property {ElementPosition} defaultPosition Specifies the `x` and `y` that the dragged item should start at.
* @property {ElementPosition} position If this property is present, the item becomes 'controlled' and is not responsive to user input.
* @property {Function} onStart Called when dragging starts. If `false` is returned any handler, the action will cancel. * @property {Function} onStart Called when dragging starts. If `false` is returned any handler, the action will cancel.
* @property {Function} onStop Called when dragging stops. * @property {Function} onStop Called when dragging stops.
* @property {Function} onDrag Called while dragging. * @property {Function} onDrag Called while dragging.
@ -45,7 +46,7 @@ function createUIDialogService() {
/** /**
* Show a new UI dialog; * Show a new UI dialog;
* *
* @param {DialogProps} props { id, content, contentProps, onStart, onDrag, onStop, isDraggable, showOverlay, defaultPosition, position } * @param {DialogProps} props { id, content, contentProps, onStart, onDrag, onStop, centralize, isDraggable, showOverlay, preservePosition, defaultPosition }
*/ */
function create({ function create({
id, id,
@ -54,10 +55,11 @@ function create({
onStart, onStart,
onDrag, onDrag,
onStop, onStop,
centralize = false,
preservePosition = true,
isDraggable = true, isDraggable = true,
showOverlay = false, showOverlay = false,
defaultPosition, defaultPosition,
position,
}) { }) {
return uiDialogServiceImplementation._create({ return uiDialogServiceImplementation._create({
id, id,
@ -66,10 +68,11 @@ function create({
onStart, onStart,
onDrag, onDrag,
onStop, onStop,
centralize,
preservePosition,
isDraggable, isDraggable,
showOverlay, showOverlay,
defaultPosition, defaultPosition,
position,
}); });
} }

View File

@ -0,0 +1,68 @@
/**
* UI Labelling Flow
*
* @typedef {Object} LabellingFlowProps
* @property {Object} defaultPosition The position of the labelling dialog.
* @property {boolean} centralize conditional to center the labelling dialog.
* @property {Object} props The labelling props.
*
*/
const uiLabellingFlowServicePublicAPI = {
name: 'UILabellingFlowService',
show,
hide,
setServiceImplementation,
};
const uiLabellingFlowServiceImplementation = {
_show: () => console.warn('show() NOT IMPLEMENTED'),
_hide: () => console.warn('hide() NOT IMPLEMENTED'),
};
function createUILabellingFlowService() {
return uiLabellingFlowServicePublicAPI;
}
/**
* Hide a UI LabellingFlow dialog;
*
*/
function hide() {
return uiLabellingFlowServiceImplementation._hide();
}
/**
* Show a new UI LabellingFlow dialog;
*
* @param {LabellingFlowProps} props { defaultPosition, centralize, props }
*/
function show({ defaultPosition, centralize, props }) {
return uiLabellingFlowServiceImplementation._show({
defaultPosition,
centralize,
props,
});
}
/**
*
*
* @param {*} {
* show: showImplementation,
* hide: hideImplementation,
* }
*/
function setServiceImplementation({
show: showImplementation,
hide: hideImplementation,
}) {
if (showImplementation) {
uiLabellingFlowServiceImplementation._show = showImplementation;
}
if (hideImplementation) {
uiLabellingFlowServiceImplementation._hide = hideImplementation;
}
}
export default createUILabellingFlowService;

View File

@ -2,10 +2,14 @@ import ServicesManager from './ServicesManager.js';
import createUINotificationService from './UINotificationService'; import createUINotificationService from './UINotificationService';
import createUIModalService from './UIModalService'; import createUIModalService from './UIModalService';
import createUIDialogService from './UIDialogService'; import createUIDialogService from './UIDialogService';
import createUIContextMenuService from './UIContextMenuService';
import createUILabellingFlowService from './UILabellingFlowService';
export { export {
createUINotificationService, createUINotificationService,
createUIModalService, createUIModalService,
createUIDialogService, createUIDialogService,
createUIContextMenuService,
createUILabellingFlowService,
ServicesManager, ServicesManager,
}; };

View File

@ -6,9 +6,7 @@
position: relative position: relative
.simpleDialog .simpleDialog
position: fixed; position: relative;
top: 0px;
left: 0px;
z-index: 1000; z-index: 1000;
border: 0; border: 0;
border-radius: 6px; border-radius: 6px;

View File

@ -0,0 +1,147 @@
import React, {
createContext,
useContext,
useEffect,
useCallback,
} from 'react';
import PropTypes from 'prop-types';
import { useDialog } from '@ohif/ui';
import { useLabellingFlow } from '@ohif/ui';
const ContextMenuContext = createContext(null);
const { Provider } = ContextMenuContext;
export const useContextMenu = () => useContext(ContextMenuContext);
const ContextMenuProvider = ({
children,
service,
contextMenuComponent: ContextMenuComponent,
onDelete,
}) => {
const { create, dismiss } = useDialog();
const { show: showLabellingFlow } = useLabellingFlow();
/**
* Sets the implementation of a context menu service that can be used by extensions.
*
* @returns void
*/
useEffect(() => {
if (service) {
service.setServiceImplementation({
show,
hide,
});
}
}, [hide, service, show]);
const hide = useCallback(() => dismiss({ id: 'context-menu' }), [dismiss]);
/**
* Show the context menu and override its configuration props.
*
* @param {ContextMenuProps} props { eventData, isTouchEvent, onClose, visible }
* @returns void
*/
const show = useCallback(
({ event }) => {
hide();
create({
id: 'context-menu',
isDraggable: false,
preservePosition: false,
content: ContextMenuComponent,
contentProps: {
eventData: event,
onDelete: (nearbyToolData, eventData) =>
onDelete(nearbyToolData, eventData),
onClose: () => dismiss({ id: 'context-menu' }),
onSetLabel: (eventData, measurementData) =>
showLabellingFlow({
event: eventData,
centralize: true,
props: {
measurementData,
skipAddLabelButton: true,
editLocation: true,
},
}),
onSetDescription: (eventData, measurementData) =>
showLabellingFlow({
event: eventData,
centralize: false,
defaultPosition: _getDefaultPosition(eventData),
props: {
measurementData,
editDescriptionOnDialog: true,
},
}),
},
defaultPosition: _getDefaultPosition(event),
});
},
[ContextMenuComponent, create, dismiss, hide, onDelete, showLabellingFlow]
);
const _getDefaultPosition = event => ({
x: (event && event.currentPoints.client.x) || 0,
y: (event && event.currentPoints.client.y) || 0,
});
return (
<Provider
value={{
show,
hide,
}}
>
{children}
</Provider>
);
};
/**
* Higher Order Component to use the context menu methods through a Class Component.
*
* @returns
*/
export const withContextMenu = Component => {
return function WrappedComponent(props) {
const { show, hide } = useContextMenu();
return (
<Component
{...props}
modal={{
show,
hide,
}}
/>
);
};
};
ContextMenuProvider.defaultProps = {
service: null,
};
ContextMenuProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
service: PropTypes.shape({
setServiceImplementation: PropTypes.func,
}),
contextMenuComponent: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
PropTypes.func,
]).isRequired,
onDelete: PropTypes.func.isRequired,
};
export default ContextMenuProvider;
export const ContextMenuConsumer = ContextMenuContext.Consumer;

View File

@ -20,7 +20,30 @@ export const useDialog = () => useContext(DialogContext);
const DialogProvider = ({ children, service }) => { const DialogProvider = ({ children, service }) => {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [dialogs, setDialogs] = useState([]); const [dialogs, setDialogs] = useState([]);
const [lastDialogId, setLastDialogId] = useState(null);
const [lastDialogPosition, setLastDialogPosition] = useState(null); const [lastDialogPosition, setLastDialogPosition] = useState(null);
const [centerPositions, setCenterPositions] = useState([]);
useEffect(() => {
setCenterPositions(
dialogs.map(dialog => ({
id: dialog.id,
...getCenterPosition(dialog.id),
}))
);
}, [dialogs]);
const getCenterPosition = id => {
const root = document.querySelector('#root');
const centerX = root.offsetLeft + root.offsetWidth / 2;
const centerY = root.offsetTop + root.offsetHeight / 2;
const item = document.querySelector(`#draggableItem-${id}`);
const itemBounds = item.getBoundingClientRect();
return {
x: centerX - itemBounds.width / 2,
y: centerY - itemBounds.height / 2,
};
};
/** /**
* Sets the implementation of a dialog service that can be used by extensions. * Sets the implementation of a dialog service that can be used by extensions.
@ -42,13 +65,16 @@ const DialogProvider = ({ children, service }) => {
* @property {Object} contentProps The dialog content props. * @property {Object} contentProps The dialog content props.
* @property {boolean} isDraggable Controls if dialog content is draggable or not. * @property {boolean} isDraggable Controls if dialog content is draggable or not.
* @property {boolean} showOverlay Controls dialog overlay. * @property {boolean} showOverlay Controls dialog overlay.
* @property {boolean} centralize Center the dialog on the screen.
* @property {boolean} preservePosition Use last position instead of default.
* @property {ElementPosition} defaultPosition Specifies the `x` and `y` that the dragged item should start at. * @property {ElementPosition} defaultPosition Specifies the `x` and `y` that the dragged item should start at.
* @property {ElementPosition} position If this property is present, the item becomes 'controlled' and is not responsive to user input.
* @property {Function} onStart Called when dragging starts. If `false` is returned any handler, the action will cancel. * @property {Function} onStart Called when dragging starts. If `false` is returned any handler, the action will cancel.
* @property {Function} onStop Called when dragging stops. * @property {Function} onStop Called when dragging stops.
* @property {Function} onDrag Called while dragging. * @property {Function} onDrag Called while dragging.
*/ */
useEffect(() => _bringToFront(lastDialogId), [_bringToFront, lastDialogId]);
/** /**
* Creates a new dialog and return its id. * Creates a new dialog and return its id.
* *
@ -64,6 +90,7 @@ const DialogProvider = ({ children, service }) => {
} }
setDialogs(dialogs => [...dialogs, { ...props, id: dialogId }]); setDialogs(dialogs => [...dialogs, { ...props, id: dialogId }]);
setLastDialogId(dialogId);
return dialogId; return dialogId;
}, []); }, []);
@ -75,9 +102,11 @@ const DialogProvider = ({ children, service }) => {
* @property {string} props.id The dialog id. * @property {string} props.id The dialog id.
* @returns void * @returns void
*/ */
const dismiss = useCallback(({ id }) => { const dismiss = useCallback(
setDialogs(dialogs => dialogs.filter(dialog => dialog.id !== id)); ({ id }) =>
}, []); setDialogs(dialogs => dialogs.filter(dialog => dialog.id !== id)),
[]
);
/** /**
* Dismisses all dialogs. * Dismisses all dialogs.
@ -101,14 +130,14 @@ const DialogProvider = ({ children, service }) => {
* @param {string} id The dialog id. * @param {string} id The dialog id.
* @returns void * @returns void
*/ */
const _bringToFront = id => { const _bringToFront = useCallback(id => {
setDialogs(dialogs => { setDialogs(dialogs => {
const topDialog = dialogs.find(dialog => dialog.id === id); const topDialog = dialogs.find(dialog => dialog.id === id);
return topDialog return topDialog
? [...dialogs.filter(dialog => dialog.id !== id), topDialog] ? [...dialogs.filter(dialog => dialog.id !== id), topDialog]
: []; : dialogs;
}); });
}; }, []);
const renderDialogs = () => const renderDialogs = () =>
dialogs.map(dialog => { dialogs.map(dialog => {
@ -116,20 +145,27 @@ const DialogProvider = ({ children, service }) => {
id, id,
content: DialogContent, content: DialogContent,
contentProps, contentProps,
position,
defaultPosition, defaultPosition,
centralize = false,
preservePosition = true,
isDraggable = true, isDraggable = true,
onStart, onStart,
onStop, onStop,
onDrag, onDrag,
} = dialog; } = dialog;
let position =
(preservePosition && lastDialogPosition) || defaultPosition;
if (centralize) {
position = centerPositions.find(position => position.id === id);
}
return ( return (
<Draggable <Draggable
key={id} key={id}
disabled={!isDraggable} disabled={!isDraggable}
position={position} position={position}
defaultPosition={lastDialogPosition || defaultPosition} defaultPosition={position}
bounds="parent" bounds="parent"
onStart={event => { onStart={event => {
const e = event || window.event; const e = event || window.event;
@ -169,7 +205,11 @@ const DialogProvider = ({ children, service }) => {
> >
<div <div
id={`draggableItem-${id}`} id={`draggableItem-${id}`}
className={classNames('DraggableItem', isDragging && 'dragging')} className={classNames(
'DraggableItem',
isDragging && 'dragging',
isDraggable && 'draggable'
)}
style={{ zIndex: '999', position: 'absolute' }} style={{ zIndex: '999', position: 'absolute' }}
onClick={() => _bringToFront(id)} onClick={() => _bringToFront(id)}
> >

View File

@ -1,8 +1,8 @@
.DraggableItem .DraggableItem.draggable
div div
cursor: grab !important cursor: grab !important
.DraggableItem.dragging .DraggableItem.draggable.dragging
div div
cursor: grabbing !important cursor: grabbing !important

View File

@ -0,0 +1,136 @@
import React, {
createContext,
useContext,
useEffect,
useCallback,
} from 'react';
import PropTypes from 'prop-types';
import { useDialog } from './DialogProvider';
const LabellingFlowContext = createContext(null);
const { Provider } = LabellingFlowContext;
export const useLabellingFlow = () => useContext(LabellingFlowContext);
const LabellingFlowProvider = ({
children,
service,
labellingComponent: LabellingComponent,
onUpdateLabelling,
}) => {
const { create, dismiss } = useDialog();
/**
* Sets the implementation of a labelling flow service that can be used by extensions.
*
* @returns void
*/
useEffect(() => {
if (service) {
service.setServiceImplementation({
show,
hide,
});
}
}, [hide, service, show]);
const hide = useCallback(() => dismiss({ id: 'labelling' }), [dismiss]);
const show = useCallback(
({ centralize, defaultPosition, props }) => {
hide();
create({
id: 'labelling',
centralize,
isDraggable: false,
showOverlay: true,
content: LabellingComponent,
defaultPosition,
contentProps: {
visible: true,
measurementData: props.measurementData,
labellingDoneCallback: () => dismiss({ id: 'labelling' }),
updateLabelling: labellingData =>
_updateLabellingHandler(labellingData, props.measurementData),
...props,
},
});
},
[LabellingComponent, _updateLabellingHandler, create, dismiss, hide]
);
const _updateLabellingHandler = useCallback(
(labellingData, measurementData) => {
const { location, description, response } = labellingData;
if (location) {
measurementData.location = location;
}
measurementData.description = description || '';
if (response) {
measurementData.response = response;
}
onUpdateLabelling(labellingData, measurementData);
},
[onUpdateLabelling]
);
return (
<Provider
value={{
show,
hide,
}}
>
{children}
</Provider>
);
};
/**
* Higher Order Component to use the labelling flow methods through a Class Component.
*
* @returns
*/
export const withLabellingFlow = Component => {
return function WrappedComponent(props) {
const { show, hide } = useLabellingFlow();
return (
<Component
{...props}
modal={{
show,
hide,
}}
/>
);
};
};
LabellingFlowProvider.defaultProps = {
service: null,
};
LabellingFlowProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
service: PropTypes.shape({
setServiceImplementation: PropTypes.func,
}),
labellingComponent: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
PropTypes.func,
]).isRequired,
onUpdateLabelling: PropTypes.func.isRequired,
};
export default LabellingFlowProvider;
export const LabellingFlowConsumer = LabellingFlowContext.Consumer;

View File

@ -18,3 +18,15 @@ export {
withDialog, withDialog,
useDialog, useDialog,
} from './DialogProvider.js'; } from './DialogProvider.js';
export {
default as ContextMenuProvider,
withContextMenu,
useContextMenu,
ContextMenuConsumer,
} from './ContextMenuProvider.js';
export {
default as LabellingFlowProvider,
withLabellingFlow,
useLabellingFlow,
LabellingFlowConsumer,
} from './LabellingFlowProvider.js';

View File

@ -60,6 +60,14 @@ import {
ModalConsumer, ModalConsumer,
useModal, useModal,
withModal, withModal,
ContextMenuProvider,
ContextMenuConsumer,
useContextMenu,
withContextMenu,
LabellingFlowProvider,
LabellingFlowConsumer,
useLabellingFlow,
withLabellingFlow,
} from './contextProviders'; } from './contextProviders';
export { export {
@ -117,6 +125,14 @@ export {
DialogProvider, DialogProvider,
withDialog, withDialog,
useDialog, useDialog,
ContextMenuProvider,
ContextMenuConsumer,
useContextMenu,
withContextMenu,
LabellingFlowProvider,
LabellingFlowConsumer,
useLabellingFlow,
withLabellingFlow,
// Hooks // Hooks
useDebounce, useDebounce,
useMedia, useMedia,

View File

@ -1,6 +1,7 @@
window.config = { window.config = {
// default: '/' // default: '/'
routerBasename: '/', routerBasename: '/',
whiteLabelling: {},
extensions: [], extensions: [],
showStudyList: true, showStudyList: true,
filterQueryParam: false, filterQueryParam: false,
@ -69,4 +70,5 @@ window.config = {
// ~ Cornerstone Tools // ~ Cornerstone Tools
{ commandName: 'setZoomTool', label: 'Zoom', keys: ['z'] }, { commandName: 'setZoomTool', label: 'Zoom', keys: ['z'] },
], ],
cornerstoneExtensionConfig: { tools: {} },
}; };

View File

@ -1,5 +1,6 @@
window.config = { window.config = {
routerBasename: '/', routerBasename: '/',
whiteLabelling: {},
extensions: [], extensions: [],
showStudyList: true, showStudyList: true,
servers: { servers: {

View File

@ -1,5 +1,6 @@
window.config = { window.config = {
routerBasename: '/', routerBasename: '/',
whiteLabelling: {},
showStudyList: true, showStudyList: true,
servers: { servers: {
dicomWeb: [ dicomWeb: [

View File

@ -1,5 +1,6 @@
window.config = { window.config = {
routerBasename: '/', routerBasename: '/',
whiteLabelling: {},
showStudyList: true, showStudyList: true,
servers: { servers: {
// This is an array, but we'll only use the first entry for now // This is an array, but we'll only use the first entry for now

View File

@ -1,5 +1,6 @@
window.config = { window.config = {
routerBasename: '/', routerBasename: '/',
whiteLabelling: {},
showStudyList: true, showStudyList: true,
servers: { servers: {
// This is an array, but we'll only use the first entry for now // This is an array, but we'll only use the first entry for now

View File

@ -1,5 +1,6 @@
window.config = { window.config = {
routerBasename: '/', routerBasename: '/',
whiteLabelling: {},
enableGoogleCloudAdapter: true, enableGoogleCloudAdapter: true,
servers: { servers: {
// This is an array, but we'll only use the first entry for now // This is an array, but we'll only use the first entry for now
@ -14,7 +15,8 @@ window.config = {
client_id: 'YOURCLIENTID.apps.googleusercontent.com', client_id: 'YOURCLIENTID.apps.googleusercontent.com',
redirect_uri: '/callback', // `OHIFStandaloneViewer.js` redirect_uri: '/callback', // `OHIFStandaloneViewer.js`
response_type: 'id_token token', response_type: 'id_token token',
scope: 'email profile openid https://www.googleapis.com/auth/cloudplatformprojects.readonly https://www.googleapis.com/auth/cloud-healthcare', // email profile openid scope:
'email profile openid https://www.googleapis.com/auth/cloudplatformprojects.readonly https://www.googleapis.com/auth/cloud-healthcare', // email profile openid
// ~ OPTIONAL // ~ OPTIONAL
post_logout_redirect_uri: '/logout-redirect.html', post_logout_redirect_uri: '/logout-redirect.html',
revoke_uri: 'https://accounts.google.com/o/oauth2/revoke?token=', revoke_uri: 'https://accounts.google.com/o/oauth2/revoke?token=',
@ -23,4 +25,4 @@ window.config = {
}, },
], ],
studyListFunctionsEnabled: true, studyListFunctionsEnabled: true,
} };

View File

@ -1,6 +1,7 @@
window.config = { window.config = {
// default: '/' // default: '/'
routerBasename: '/', routerBasename: '/',
whiteLabelling: {},
// default: '' // default: ''
showStudyList: true, showStudyList: true,
servers: { servers: {

View File

@ -1,5 +1,6 @@
window.config = { window.config = {
routerBasename: '/pwa', routerBasename: '/pwa',
whiteLabelling: {},
showStudyList: true, showStudyList: true,
servers: { servers: {
dicomWeb: [ dicomWeb: [

View File

@ -1,5 +1,6 @@
window.config = { window.config = {
routerBasename: '/', routerBasename: '/',
whiteLabelling: {},
showStudyList: true, showStudyList: true,
servers: { servers: {
dicomWeb: [ dicomWeb: [

View File

@ -1,74 +1,102 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<meta name="theme-color" content="#000000" />
<meta name="mobile-web-app-capable" content="yes">
<meta name="application-name" content="OHIF Viewer">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="@ohif/viewer">
<meta name="msapplication-TileColor" content="#fff">
<meta name="msapplication-TileImage" content="<%= PUBLIC_URL %>assets/mstile-144x144.png">
<meta name="msapplication-config" content="<%= PUBLIC_URL %>assets/browserconfig.xml">
<link rel="manifest" href="<%= PUBLIC_URL %>manifest.json">
<link rel="shortcut icon" href="<%= PUBLIC_URL %>assets/favicon.ico"> <head>
<link rel="icon" type="image/png" sizes="16x16" href="<%= PUBLIC_URL %>assets/favicon-16x16.png"> <meta charset="utf-8" />
<link rel="icon" type="image/png" sizes="32x32" href="<%= PUBLIC_URL %>assets/favicon-32x32.png"> <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
<link rel="apple-touch-icon" sizes="57x57" href="<%= PUBLIC_URL %>assets/apple-touch-icon-57x57.png"> <meta name="theme-color" content="#000000" />
<link rel="apple-touch-icon" sizes="60x60" href="<%= PUBLIC_URL %>assets/apple-touch-icon-60x60.png"> <meta name="mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" sizes="72x72" href="<%= PUBLIC_URL %>assets/apple-touch-icon-72x72.png"> <meta name="application-name" content="OHIF Viewer">
<link rel="apple-touch-icon" sizes="76x76" href="<%= PUBLIC_URL %>assets/apple-touch-icon-76x76.png"> <meta name="apple-mobile-web-app-capable" content="yes">
<link rel="apple-touch-icon" sizes="114x114" href="<%= PUBLIC_URL %>assets/apple-touch-icon-114x114.png"> <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-icon" sizes="120x120" href="<%= PUBLIC_URL %>assets/apple-touch-icon-120x120.png"> <meta name="apple-mobile-web-app-title" content="@ohif/viewer">
<link rel="apple-touch-icon" sizes="144x144" href="<%= PUBLIC_URL %>assets/apple-touch-icon-144x144.png"> <meta name="msapplication-TileColor" content="#fff">
<link rel="apple-touch-icon" sizes="152x152" href="<%= PUBLIC_URL %>assets/apple-touch-icon-152x152.png"> <meta name="msapplication-TileImage" content="<%= PUBLIC_URL %>assets/mstile-144x144.png">
<link rel="apple-touch-icon" sizes="167x167" href="<%= PUBLIC_URL %>assets/apple-touch-icon-167x167.png"> <meta name="msapplication-config" content="<%= PUBLIC_URL %>assets/browserconfig.xml">
<link rel="apple-touch-icon" sizes="180x180" href="<%= PUBLIC_URL %>assets/apple-touch-icon-180x180.png"> <link rel="manifest" href="<%= PUBLIC_URL %>manifest.json">
<link rel="apple-touch-icon" sizes="1024x1024" href="<%= PUBLIC_URL %>assets/apple-touch-icon-1024x1024.png">
<link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 480px) and (-webkit-device-pixel-ratio: 1)" href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-320x460.png">
<link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 480px) and (-webkit-device-pixel-ratio: 2)" href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-640x920.png">
<link rel="apple-touch-startup-image" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)" href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-640x1096.png">
<link rel="apple-touch-startup-image" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)" href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-750x1294.png">
<link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (orientation: landscape) and (-webkit-device-pixel-ratio: 3)" href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-1182x2208.png">
<link rel="apple-touch-startup-image" media="(device-width: 414px) and (device-height: 736px) and (orientation: portrait) and (-webkit-device-pixel-ratio: 3)" href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-1242x2148.png">
<link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (orientation: landscape) and (-webkit-device-pixel-ratio: 1)" href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-748x1024.png">
<link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (orientation: portrait) and (-webkit-device-pixel-ratio: 1)" href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-768x1004.png">
<link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (orientation: landscape) and (-webkit-device-pixel-ratio: 2)" href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-1496x2048.png">
<link rel="apple-touch-startup-image" media="(device-width: 768px) and (device-height: 1024px) and (orientation: portrait) and (-webkit-device-pixel-ratio: 2)" href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-1536x2008.png">
<link rel="icon" type="image/png" sizes="228x228" href="<%= PUBLIC_URL %>assets/coast-228x228.png">
<link rel="yandex-tableau-widget" href="<%= PUBLIC_URL %>assets/yandex-browser-manifest.json">
<!-- Built with: https://polyfill.io/v3/url-builder/ --> <link rel="shortcut icon" href="<%= PUBLIC_URL %>assets/favicon.ico">
<!-- Targets IE11 --> <link rel="icon" type="image/png" sizes="16x16" href="<%= PUBLIC_URL %>assets/favicon-16x16.png">
<script src="https://polyfill.io/v3/polyfill.min.js?flags=gated&features=default%2CObject.values%2CArray.prototype.flat%2CObject.entries%2CSymbol%2CArray.prototype.includes%2CString.prototype.repeat%2CArray.prototype.find"></script> <link rel="icon" type="image/png" sizes="32x32" href="<%= PUBLIC_URL %>assets/favicon-32x32.png">
<script type="text/javascript"> <link rel="apple-touch-icon" sizes="57x57" href="<%= PUBLIC_URL %>assets/apple-touch-icon-57x57.png">
window.PUBLIC_URL = '<%= PUBLIC_URL %>'; <link rel="apple-touch-icon" sizes="60x60" href="<%= PUBLIC_URL %>assets/apple-touch-icon-60x60.png">
</script> <link rel="apple-touch-icon" sizes="72x72" href="<%= PUBLIC_URL %>assets/apple-touch-icon-72x72.png">
<script type="text/javascript" src="<%= PUBLIC_URL %>app-config.js"></script> <link rel="apple-touch-icon" sizes="76x76" href="<%= PUBLIC_URL %>assets/apple-touch-icon-76x76.png">
<script type="module" src="<%= PUBLIC_URL %>init-service-worker.js"></script> <link rel="apple-touch-icon" sizes="114x114" href="<%= PUBLIC_URL %>assets/apple-touch-icon-114x114.png">
<link rel="apple-touch-icon" sizes="120x120" href="<%= PUBLIC_URL %>assets/apple-touch-icon-120x120.png">
<link rel="apple-touch-icon" sizes="144x144" href="<%= PUBLIC_URL %>assets/apple-touch-icon-144x144.png">
<link rel="apple-touch-icon" sizes="152x152" href="<%= PUBLIC_URL %>assets/apple-touch-icon-152x152.png">
<link rel="apple-touch-icon" sizes="167x167" href="<%= PUBLIC_URL %>assets/apple-touch-icon-167x167.png">
<link rel="apple-touch-icon" sizes="180x180" href="<%= PUBLIC_URL %>assets/apple-touch-icon-180x180.png">
<link rel="apple-touch-icon" sizes="1024x1024" href="<%= PUBLIC_URL %>assets/apple-touch-icon-1024x1024.png">
<link rel="apple-touch-startup-image"
media="(device-width: 320px) and (device-height: 480px) and (-webkit-device-pixel-ratio: 1)"
href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-320x460.png">
<link rel="apple-touch-startup-image"
media="(device-width: 320px) and (device-height: 480px) and (-webkit-device-pixel-ratio: 2)"
href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-640x920.png">
<link rel="apple-touch-startup-image"
media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2)"
href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-640x1096.png">
<link rel="apple-touch-startup-image"
media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2)"
href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-750x1294.png">
<link rel="apple-touch-startup-image"
media="(device-width: 414px) and (device-height: 736px) and (orientation: landscape) and (-webkit-device-pixel-ratio: 3)"
href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-1182x2208.png">
<link rel="apple-touch-startup-image"
media="(device-width: 414px) and (device-height: 736px) and (orientation: portrait) and (-webkit-device-pixel-ratio: 3)"
href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-1242x2148.png">
<link rel="apple-touch-startup-image"
media="(device-width: 768px) and (device-height: 1024px) and (orientation: landscape) and (-webkit-device-pixel-ratio: 1)"
href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-748x1024.png">
<link rel="apple-touch-startup-image"
media="(device-width: 768px) and (device-height: 1024px) and (orientation: portrait) and (-webkit-device-pixel-ratio: 1)"
href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-768x1004.png">
<link rel="apple-touch-startup-image"
media="(device-width: 768px) and (device-height: 1024px) and (orientation: landscape) and (-webkit-device-pixel-ratio: 2)"
href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-1496x2048.png">
<link rel="apple-touch-startup-image"
media="(device-width: 768px) and (device-height: 1024px) and (orientation: portrait) and (-webkit-device-pixel-ratio: 2)"
href="<%= PUBLIC_URL %>assets/apple-touch-startup-image-1536x2008.png">
<link rel="icon" type="image/png" sizes="228x228" href="<%= PUBLIC_URL %>assets/coast-228x228.png">
<link rel="yandex-tableau-widget" href="<%= PUBLIC_URL %>assets/yandex-browser-manifest.json">
<title>OHIF Viewer</title> <!-- Built with: https://polyfill.io/v3/url-builder/ -->
<!-- Targets IE11 -->
<script
src="https://polyfill.io/v3/polyfill.min.js?flags=gated&features=default%2CObject.values%2CArray.prototype.flat%2CObject.entries%2CSymbol%2CArray.prototype.includes%2CString.prototype.repeat%2CArray.prototype.find"></script>
<script type="text/javascript">
window.PUBLIC_URL = '<%= PUBLIC_URL %>';
</script>
<script type="text/javascript" src="<%= PUBLIC_URL %>app-config.js"></script>
<script type="module" src="<%= PUBLIC_URL %>init-service-worker.js"></script>
<!-- WEB FONTS --> <title>OHIF Viewer</title>
<link
href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700&display=swap"
rel="stylesheet"
/>
<!-- EXTENSIONS --> <!-- WEB FONTS -->
<!-- <script type="text/javascript" src="path/to/some-extension.js"></script> <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700&display=swap" rel="stylesheet" />
<!-- EXTENSIONS -->
<!-- <script type="text/javascript" src="path/to/some-extension.js"></script>
<script> <script>
// If configuration file is a JS Object:
window.config.extensions = [SomeExtension]; window.config.extensions = [SomeExtension];
// If configuration file is a JS Function:
window.config = () => {
return {
extensions: [SomeExtension]
};
};
</script> --> </script> -->
</head> </head>
<body> <body>
<noscript> You need to enable JavaScript to run this app. </noscript> <noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root"></div>
</body>
<div id="root"></div>
</body>
</html> </html>

File diff suppressed because one or more lines are too long

View File

@ -4,9 +4,13 @@ import { I18nextProvider } from 'react-i18next';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { BrowserRouter as Router } from 'react-router-dom'; import { BrowserRouter as Router } from 'react-router-dom';
import OHIFCornerstoneExtension from '@ohif/extension-cornerstone';
import { hot } from 'react-hot-loader/root'; import { hot } from 'react-hot-loader/root';
import OHIFCornerstoneExtension from '@ohif/extension-cornerstone';
import ToolContextMenu from './connectedComponents/ToolContextMenu';
import LabellingManager from './components/Labelling/LabellingManager';
import { import {
SnackbarProvider, SnackbarProvider,
ModalProvider, ModalProvider,
@ -14,6 +18,11 @@ import {
OHIFModal, OHIFModal,
} from '@ohif/ui'; } from '@ohif/ui';
import {
LabellingFlowProvider,
ContextMenuProvider,
} from './appCustomProviders';
import { import {
CommandsManager, CommandsManager,
ExtensionManager, ExtensionManager,
@ -22,8 +31,10 @@ import {
createUINotificationService, createUINotificationService,
createUIModalService, createUIModalService,
createUIDialogService, createUIDialogService,
createUIContextMenuService,
createUILabellingFlowService,
utils, utils,
redux as reduxOHIF redux as reduxOHIF,
} from '@ohif/core'; } from '@ohif/core';
import i18n from '@ohif/i18n'; import i18n from '@ohif/i18n';
@ -46,12 +57,12 @@ import OHIFStandaloneViewer from './OHIFStandaloneViewer';
/** Store */ /** Store */
import { getActiveContexts } from './store/layout/selectors.js'; import { getActiveContexts } from './store/layout/selectors.js';
import store from './store'; import store from './store';
const { setUserPreferences } = reduxOHIF.actions;
/** Contexts */ /** Contexts */
import WhiteLabellingContext from './context/WhiteLabellingContext'; import WhiteLabellingContext from './context/WhiteLabellingContext';
import UserManagerContext from './context/UserManagerContext'; import UserManagerContext from './context/UserManagerContext';
import AppContext from './context/AppContext'; import AppContext from './context/AppContext';
const { setUserPreferences } = reduxOHIF.actions;
/** ~~~~~~~~~~~~~ Application Setup */ /** ~~~~~~~~~~~~~ Application Setup */
const commandsManagerConfig = { const commandsManagerConfig = {
@ -63,6 +74,8 @@ const commandsManagerConfig = {
const UINotificationService = createUINotificationService(); const UINotificationService = createUINotificationService();
const UIModalService = createUIModalService(); const UIModalService = createUIModalService();
const UIDialogService = createUIDialogService(); const UIDialogService = createUIDialogService();
const UIContextMenuService = createUIContextMenuService();
const UILabellingFlowService = createUILabellingFlowService();
/** Managers */ /** Managers */
const commandsManager = new CommandsManager(commandsManagerConfig); const commandsManager = new CommandsManager(commandsManagerConfig);
@ -79,23 +92,25 @@ window.store = store;
class App extends Component { class App extends Component {
static propTypes = { static propTypes = {
routerBasename: PropTypes.string.isRequired, config: PropTypes.oneOfType([
servers: PropTypes.object.isRequired, PropTypes.func,
//
oidc: PropTypes.array,
whiteLabelling: PropTypes.object,
extensions: PropTypes.arrayOf(
PropTypes.shape({ PropTypes.shape({
id: PropTypes.string.isRequired, routerBasename: PropTypes.string.isRequired,
}) oidc: PropTypes.array,
), whiteLabelling: PropTypes.object,
hotkeys: PropTypes.array, extensions: PropTypes.array,
}),
]).isRequired,
defaultExtensions: PropTypes.array,
}; };
static defaultProps = { static defaultProps = {
whiteLabelling: {}, config: {
oidc: [], whiteLabelling: {},
extensions: [], oidc: [],
extensions: [],
},
defaultExtensions: [],
}; };
_appConfig; _appConfig;
@ -104,31 +119,59 @@ class App extends Component {
constructor(props) { constructor(props) {
super(props); super(props);
this._appConfig = props; const { config, defaultExtensions } = props;
const { servers, extensions, hotkeys, oidc } = props; const appDefaultConfig = {
cornerstoneExtensionConfig: {},
extensions: [],
routerBasename: '/',
whiteLabelling: {},
};
this._appConfig = {
...appDefaultConfig,
...(typeof config === 'function' ? config({ servicesManager }) : config),
};
const {
servers,
hotkeys,
cornerstoneExtensionConfig,
extensions,
oidc,
} = this._appConfig;
this.initUserManager(oidc); this.initUserManager(oidc);
_initServices([UINotificationService, UIModalService, UIDialogService]); _initServices([
_initExtensions(extensions, hotkeys); UINotificationService,
UIModalService,
UIDialogService,
UIContextMenuService,
UILabellingFlowService,
]);
_initExtensions(
[...defaultExtensions, ...extensions],
cornerstoneExtensionConfig
);
/*
* Must run after extension commands are registered
* if there is no hotkeys from localStorage set up from config.
*/
_initHotkeys(hotkeys);
_initServers(servers); _initServers(servers);
initWebWorkers(); initWebWorkers();
} }
render() { render() {
const { whiteLabelling, routerBasename } = this.props; const { whiteLabelling, routerBasename } = this._appConfig;
const userManager = this._userManager; if (this._userManager) {
const config = {
appConfig: this._appConfig,
};
if (userManager) {
return ( return (
<AppContext.Provider value={config}> <AppContext.Provider value={{ appConfig: this._appConfig }}>
<Provider store={store}> <Provider store={store}>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<OidcProvider store={store} userManager={userManager}> <OidcProvider store={store} userManager={this._userManager}>
<UserManagerContext.Provider value={userManager}> <UserManagerContext.Provider value={this._userManager}>
<Router basename={routerBasename}> <Router basename={routerBasename}>
<WhiteLabellingContext.Provider value={whiteLabelling}> <WhiteLabellingContext.Provider value={whiteLabelling}>
<SnackbarProvider service={UINotificationService}> <SnackbarProvider service={UINotificationService}>
@ -137,7 +180,21 @@ class App extends Component {
modal={OHIFModal} modal={OHIFModal}
service={UIModalService} service={UIModalService}
> >
<OHIFStandaloneViewer userManager={userManager} /> <LabellingFlowProvider
service={UILabellingFlowService}
labellingComponent={LabellingManager}
commandsManager={commandsManager}
>
<ContextMenuProvider
service={UIContextMenuService}
contextMenuComponent={ToolContextMenu}
commandsManager={commandsManager}
>
<OHIFStandaloneViewer
userManager={this._userManager}
/>
</ContextMenuProvider>
</LabellingFlowProvider>
</ModalProvider> </ModalProvider>
</DialogProvider> </DialogProvider>
</SnackbarProvider> </SnackbarProvider>
@ -152,7 +209,7 @@ class App extends Component {
} }
return ( return (
<AppContext.Provider value={config}> <AppContext.Provider value={{ appConfig: this._appConfig }}>
<Provider store={store}> <Provider store={store}>
<I18nextProvider i18n={i18n}> <I18nextProvider i18n={i18n}>
<Router basename={routerBasename}> <Router basename={routerBasename}>
@ -160,7 +217,19 @@ class App extends Component {
<SnackbarProvider service={UINotificationService}> <SnackbarProvider service={UINotificationService}>
<DialogProvider service={UIDialogService}> <DialogProvider service={UIDialogService}>
<ModalProvider modal={OHIFModal} service={UIModalService}> <ModalProvider modal={OHIFModal} service={UIModalService}>
<OHIFStandaloneViewer /> <LabellingFlowProvider
service={UILabellingFlowService}
labellingComponent={LabellingManager}
commandsManager={commandsManager}
>
<ContextMenuProvider
service={UIContextMenuService}
contextMenuComponent={ToolContextMenu}
commandsManager={commandsManager}
>
<OHIFStandaloneViewer />
</ContextMenuProvider>
</LabellingFlowProvider>
</ModalProvider> </ModalProvider>
</DialogProvider> </DialogProvider>
</SnackbarProvider> </SnackbarProvider>
@ -174,10 +243,10 @@ class App extends Component {
initUserManager(oidc) { initUserManager(oidc) {
if (oidc && !!oidc.length) { if (oidc && !!oidc.length) {
const firstOpenIdClient = this.props.oidc[0]; const firstOpenIdClient = this._appConfig.oidc[0];
const { protocol, host } = window.location; const { protocol, host } = window.location;
const { routerBasename } = this.props; const { routerBasename } = this._appConfig;
const baseUri = `${protocol}//${host}${routerBasename}`; const baseUri = `${protocol}//${host}${routerBasename}`;
const redirect_uri = firstOpenIdClient.redirect_uri || '/callback'; const redirect_uri = firstOpenIdClient.redirect_uri || '/callback';
@ -213,22 +282,22 @@ function _initServices(services) {
/** /**
* @param * @param
*/ */
function _initExtensions(extensions, hotkeys) { function _initExtensions(extensions, cornerstoneExtensionConfig) {
const defaultExtensions = [ const requiredExtensions = [
GenericViewerCommands, GenericViewerCommands,
OHIFCornerstoneExtension, [OHIFCornerstoneExtension, cornerstoneExtensionConfig],
// WARNING: MUST BE REGISTERED _AFTER_ OHIFCORNERSTONEEXTENSION /* WARNING: MUST BE REGISTERED _AFTER_ OHIFCornerstoneExtension */
MeasurementsPanel, MeasurementsPanel,
]; ];
const mergedExtensions = defaultExtensions.concat(extensions); const mergedExtensions = requiredExtensions.concat(extensions);
extensionManager.registerExtensions(mergedExtensions); extensionManager.registerExtensions(mergedExtensions);
}
function _initHotkeys(hotkeys) {
const { hotkeyDefinitions = {} } = store.getState().preferences || {}; const { hotkeyDefinitions = {} } = store.getState().preferences || {};
let updateStore = false; let updateStore = false;
let hotkeysToUse = hotkeyDefinitions; let hotkeysToUse = hotkeyDefinitions;
// Must run after extension commands are registered
// if there is no hotkeys from localStorate set up from config
if (!Object.keys(hotkeyDefinitions).length) { if (!Object.keys(hotkeyDefinitions).length) {
hotkeysToUse = hotkeys; hotkeysToUse = hotkeys;
updateStore = true; updateStore = true;
@ -236,7 +305,8 @@ function _initExtensions(extensions, hotkeys) {
if (hotkeysToUse) { if (hotkeysToUse) {
hotkeysManager.setHotkeys(hotkeysToUse); hotkeysManager.setHotkeys(hotkeysToUse);
// set default based on app config
/* Set hotkeys default based on app config. */
hotkeysManager.setDefaultHotKeys(hotkeys); hotkeysManager.setDefaultHotKeys(hotkeys);
if (updateStore) { if (updateStore) {
@ -264,7 +334,9 @@ function _makeAbsoluteIfNecessary(url, base_url) {
return url; return url;
} }
// Make sure base_url and url are not duplicating slashes /*
* Make sure base_url and url are not duplicating slashes.
*/
if (base_url[base_url.length - 1] === '/') { if (base_url[base_url.length - 1] === '/') {
base_url = base_url.slice(0, base_url.length - 1); base_url = base_url.slice(0, base_url.length - 1);
} }
@ -272,7 +344,9 @@ function _makeAbsoluteIfNecessary(url, base_url) {
return base_url + url; return base_url + url;
} }
// Only wrap/use hot if in dev /*
* Only wrap/use hot if in dev.
*/
const ExportedApp = process.env.NODE_ENV === 'development' ? hot(App) : App; const ExportedApp = process.env.NODE_ENV === 'development' ? hot(App) : App;
export default ExportedApp; export default ExportedApp;

View File

@ -0,0 +1,51 @@
import React from 'react';
import PropTypes from 'prop-types';
import { ContextMenuProvider } from '@ohif/ui';
const CustomContextMenuProvider = ({
children,
service,
contextMenuComponent,
commandsManager,
}) => {
const onDeleteHandler = (nearbyToolData, eventData) => {
const element = eventData.element;
commandsManager.runCommand('removeToolState', {
element,
toolType: nearbyToolData.toolType,
tool: nearbyToolData.tool,
});
};
return (
<ContextMenuProvider
service={service}
contextMenuComponent={contextMenuComponent}
onDelete={onDeleteHandler}
>
{children}
</ContextMenuProvider>
);
};
CustomContextMenuProvider.defaultProps = {
service: null,
};
CustomContextMenuProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
service: PropTypes.shape({
setServiceImplementation: PropTypes.func,
}),
contextMenuComponent: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
PropTypes.func,
]).isRequired,
commandsManager: PropTypes.object.isRequired,
};
export default CustomContextMenuProvider;

View File

@ -0,0 +1,49 @@
import React from 'react';
import PropTypes from 'prop-types';
import { LabellingFlowProvider } from '@ohif/ui';
const CustomLabellingFlowProvider = ({
children,
service,
labellingComponent,
commandsManager,
}) => {
const onUpdateLabellingHandler = (labellingData, measurementData) => {
commandsManager.runCommand(
'updateTableWithNewMeasurementData',
measurementData
);
};
return (
<LabellingFlowProvider
service={service}
labellingComponent={labellingComponent}
onUpdateLabelling={onUpdateLabellingHandler}
>
{children}
</LabellingFlowProvider>
);
};
CustomLabellingFlowProvider.defaultProps = {
service: null,
};
CustomLabellingFlowProvider.propTypes = {
children: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
]).isRequired,
service: PropTypes.shape({
setServiceImplementation: PropTypes.func,
}),
labellingComponent: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.node),
PropTypes.node,
PropTypes.func,
]).isRequired,
commandsManager: PropTypes.object.isRequired,
};
export default CustomLabellingFlowProvider;

View File

@ -0,0 +1,6 @@
export {
default as LabellingFlowProvider,
} from './LabellingFlowProvider/LabellingFlowProvider.js';
export {
default as ContextMenuProvider,
} from './ContextMenuProvider/ContextMenuProvider.js';

View File

@ -4,9 +4,7 @@ import OHIF from '@ohif/core';
import moment from 'moment'; import moment from 'moment';
import cornerstone from 'cornerstone-core'; import cornerstone from 'cornerstone-core';
//
import jumpToRowItem from './jumpToRowItem.js'; import jumpToRowItem from './jumpToRowItem.js';
import getMeasurementLocationCallback from './getMeasurementLocationCallback';
const { setViewportSpecificData } = OHIF.redux.actions; const { setViewportSpecificData } = OHIF.redux.actions;
const { MeasurementApi } = OHIF.measurements; const { MeasurementApi } = OHIF.measurements;
@ -155,9 +153,11 @@ const mapStateToProps = state => {
}; };
}; };
const mapDispatchToProps = dispatch => { const mapDispatchToProps = (dispatch, ownProps) => {
return { return {
dispatchRelabel: (event, measurementData, viewportsState) => { dispatchRelabel: (event, measurementData, viewportsState) => {
event.persist();
const activeViewportIndex = const activeViewportIndex =
(viewportsState && viewportsState.activeViewportIndex) || 0; (viewportsState && viewportsState.activeViewportIndex) || 0;
@ -167,31 +167,21 @@ const mapDispatchToProps = dispatch => {
return; return;
} }
const { element } = enabledElements[activeViewportIndex];
const eventData = {
event: {
clientX: event.clientX,
clientY: event.clientY,
},
element,
};
const { toolType, measurementId } = measurementData; const { toolType, measurementId } = measurementData;
const tool = MeasurementApi.Instance.tools[toolType].find(measurement => { const tool = MeasurementApi.Instance.tools[toolType].find(measurement => {
return measurement._id === measurementId; return measurement._id === measurementId;
}); });
const options = {
skipAddLabelButton: true,
editLocation: true,
};
// Clone the tool not to set empty location initially // Clone the tool not to set empty location initially
const toolForLocation = Object.assign({}, tool, { location: null }); const toolForLocation = Object.assign({}, tool, { location: null });
getMeasurementLocationCallback(eventData, toolForLocation, options);
if (ownProps.onRelabel) {
ownProps.onRelabel(toolForLocation);
}
}, },
dispatchEditDescription: (event, measurementData, viewportsState) => { dispatchEditDescription: (event, measurementData, viewportsState) => {
event.persist();
const activeViewportIndex = const activeViewportIndex =
(viewportsState && viewportsState.activeViewportIndex) || 0; (viewportsState && viewportsState.activeViewportIndex) || 0;
@ -201,26 +191,14 @@ const mapDispatchToProps = dispatch => {
return; return;
} }
const { element } = enabledElements[activeViewportIndex];
const eventData = {
event: {
clientX: event.clientX,
clientY: event.clientY,
},
element,
};
const { toolType, measurementId } = measurementData; const { toolType, measurementId } = measurementData;
const tool = MeasurementApi.Instance.tools[toolType].find(measurement => { const tool = MeasurementApi.Instance.tools[toolType].find(measurement => {
return measurement._id === measurementId; return measurement._id === measurementId;
}); });
const options = { if (ownProps.onEditDescription) {
editDescriptionOnDialog: true, ownProps.onEditDescription(tool);
}; }
getMeasurementLocationCallback(eventData, tool, options);
}, },
dispatchJumpToRowItem: ( dispatchJumpToRowItem: (
measurementData, measurementData,

View File

@ -1,21 +0,0 @@
const setLabellingFlowDataAction = labellingFlowData => ({
type: 'SET_LABELLING_FLOW_DATA',
labellingFlowData,
});
const resetLabellingAndContextMenuAction = state => ({
type: 'RESET_LABELLING_AND_CONTEXT_MENU',
state,
});
const setToolContextMenuDataAction = (viewportIndex, toolContextMenuData) => ({
type: 'SET_TOOL_CONTEXT_MENU_DATA',
viewportIndex,
toolContextMenuData,
});
export {
resetLabellingAndContextMenuAction,
setLabellingFlowDataAction,
setToolContextMenuDataAction,
};

View File

@ -1,33 +0,0 @@
import cornerstoneTools from 'cornerstone-tools';
import updateTableWithNewMeasurementData from './updateTableWithNewMeasurementData.js';
export default function getMeasurementLocationCallback(
eventData,
tool,
options
) {
const { toolType } = tool;
const { element } = eventData;
const doneCallback = updateTableWithNewMeasurementData;
const ToolInstance = cornerstoneTools.getToolForElement(element, toolType);
if (
!ToolInstance ||
!ToolInstance.configuration ||
!ToolInstance.configuration.getMeasurementLocationCallback
) {
console.warn(
'Tool instance configuration is missing: getMeasurementLocationCallback'
);
return;
}
ToolInstance.configuration.getMeasurementLocationCallback(
tool,
eventData,
doneCallback,
options
);
}

View File

@ -1,3 +1,4 @@
import React from 'react';
import ConnectedMeasurementTable from './ConnectedMeasurementTable.js'; import ConnectedMeasurementTable from './ConnectedMeasurementTable.js';
import init from './init.js'; import init from './init.js';
@ -7,10 +8,38 @@ export default {
*/ */
id: 'measurements-table', id: 'measurements-table',
preRegistration({ servicesManager, configuration = {} }) { preRegistration({ servicesManager, commandsManager, configuration = {} }) {
init({ servicesManager, configuration }); init({ servicesManager, commandsManager, configuration });
}, },
getPanelModule({ servicesManager }) { getPanelModule({ servicesManager, commandsManager }) {
const { UILabellingFlowService } = servicesManager.services;
const ExtendedConnectedMeasurementTable = () => (
<ConnectedMeasurementTable
onRelabel={tool => {
if (UILabellingFlowService) {
UILabellingFlowService.show({
centralize: true,
props: {
skipAddLabelButton: true,
editLocation: true,
measurementData: tool,
},
});
}
}}
onEditDescription={tool => {
if (UILabellingFlowService) {
UILabellingFlowService.show({
centralize: true,
props: {
editDescriptionOnDialog: true,
measurementData: tool,
},
});
}
}}
/>
);
return { return {
menuOptions: [ menuOptions: [
{ {
@ -22,7 +51,7 @@ export default {
components: [ components: [
{ {
id: 'measurement-panel', id: 'measurement-panel',
component: ConnectedMeasurementTable, component: ExtendedConnectedMeasurementTable,
}, },
], ],
defaultContext: ['VIEWER'], defaultContext: ['VIEWER'],

View File

@ -1,19 +1,7 @@
import OHIF from '@ohif/core'; import OHIF from '@ohif/core';
import cornerstone from 'cornerstone-core'; import cornerstone from 'cornerstone-core';
import csTools from 'cornerstone-tools'; import csTools from 'cornerstone-tools';
import {
getToolLabellingFlowCallback,
getOnRightClickCallback,
getOnTouchPressCallback,
getResetLabellingAndContextMenu,
} from './labelingFlowCallbacks.js';
import throttle from 'lodash.throttle'; import throttle from 'lodash.throttle';
import { SimpleDialog } from '@ohif/ui';
// TODO: This only works because we have a hard dependency on this extension
// We need to decouple and make stuff like this possible w/o bundling this at
// build time
import store from './../../store';
const { const {
onAdded, onAdded,
@ -33,92 +21,18 @@ const MEASUREMENT_ACTION_MAP = {
* *
* *
* @export * @export
* @param {*} configuration * @param {Object} servicesManager
* @param {Object} configuration
*/ */
export default function init({ servicesManager, configuration = {} }) { export default function init({
const { UIDialogService } = servicesManager.services; servicesManager,
const callInputDialog = (data, event, callback) => { commandsManager,
let dialogId = UIDialogService.create({ configuration,
content: SimpleDialog.InputDialog, }) {
defaultPosition: { const {
x: (event && event.currentPoints.canvas.x) || 0, UIContextMenuService,
y: (event && event.currentPoints.canvas.y) || 0, UILabellingFlowService,
}, } = servicesManager.services;
showOverlay: true,
contentProps: {
title: 'Enter your annotation',
label: 'New label',
defaultValue: data ? data.text : '',
onClose: () => UIDialogService.dismiss({ id: dialogId }),
onSubmit: value => {
callback(value);
UIDialogService.dismiss({ id: dialogId });
},
},
});
};
// If these tools were already added by a different extension, we want to replace
// them with the same tools that have an alternative configuration. By passing in
// our custom `getMeasurementLocationCallback`, we can...
const toolLabellingFlowCallback = getToolLabellingFlowCallback(store);
// Removes all tools from all enabled elements w/ provided name
// Not commonly used API, so :eyes: for unknown side-effects
csTools.removeTool('Bidirectional');
csTools.removeTool('Length');
csTools.removeTool('Angle');
csTools.removeTool('FreehandRoi');
csTools.removeTool('EllipticalRoi');
csTools.removeTool('CircleRoi');
csTools.removeTool('RectangleRoi');
csTools.removeTool('ArrowAnnotate');
// Re-add each tool w/ our custom configuration
csTools.addTool(csTools.BidirectionalTool, {
configuration: {
getMeasurementLocationCallback: toolLabellingFlowCallback,
},
});
csTools.addTool(csTools.LengthTool, {
configuration: {
getMeasurementLocationCallback: toolLabellingFlowCallback,
},
});
csTools.addTool(csTools.AngleTool, {
configuration: {
getMeasurementLocationCallback: toolLabellingFlowCallback,
},
});
csTools.addTool(csTools.FreehandRoiTool, {
configuration: {
getMeasurementLocationCallback: toolLabellingFlowCallback,
},
});
csTools.addTool(csTools.EllipticalRoiTool, {
configuration: {
getMeasurementLocationCallback: toolLabellingFlowCallback,
},
});
csTools.addTool(csTools.CircleRoiTool, {
configuration: {
getMeasurementLocationCallback: toolLabellingFlowCallback,
},
});
csTools.addTool(csTools.RectangleRoiTool, {
configuration: {
getMeasurementLocationCallback: toolLabellingFlowCallback,
},
});
csTools.addTool(csTools.ArrowAnnotateTool, {
configuration: {
getMeasurementLocationCallback: toolLabellingFlowCallback,
getTextCallback: (callback, eventDetails) =>
callInputDialog(null, eventDetails, callback),
changeTextCallback: (data, eventDetails, callback) =>
callInputDialog(data, eventDetails, callback),
},
});
// TODO: MEASUREMENT_COMPLETED (not present in initial implementation) // TODO: MEASUREMENT_COMPLETED (not present in initial implementation)
const onMeasurementsChanged = (action, event) => { const onMeasurementsChanged = (action, event) => {
@ -131,15 +45,43 @@ export default function init({ servicesManager, configuration = {} }) {
this, this,
'labelmapModified' 'labelmapModified'
); );
//
const onRightClick = getOnRightClickCallback(store);
const onTouchPress = getOnTouchPressCallback(store);
const onNewImage = getResetLabellingAndContextMenu(store);
const onMouseClick = getResetLabellingAndContextMenu(store);
const onTouchStart = getResetLabellingAndContextMenu(store);
// Because click gives us the native "mouse up", buttons will always be `0` const onRightClick = event => {
// Need to fallback to event.which; if (UIContextMenuService) {
UIContextMenuService.show({ event: event.detail });
}
};
const onTouchPress = event => {
if (UIContextMenuService) {
UIContextMenuService.show({
event: event.detail,
props: {
isTouchEvent: true,
},
});
}
};
const onTouchStart = () => resetLabelligAndContextMenu();
const onMouseClick = () => resetLabelligAndContextMenu();
const resetLabelligAndContextMenu = () => {
if (UILabellingFlowService && UIContextMenuService) {
UILabellingFlowService.hide();
UIContextMenuService.hide();
}
};
// TODO: This makes scrolling painfully slow
// const onNewImage = ...
/*
* Because click gives us the native "mouse up", buttons will always be `0`
* Need to fallback to event.which;
*
*/
const handleClick = cornerstoneMouseClickEvent => { const handleClick = cornerstoneMouseClickEvent => {
const mouseUpEvent = cornerstoneMouseClickEvent.detail.event; const mouseUpEvent = cornerstoneMouseClickEvent.detail.event;
const isRightClick = mouseUpEvent.which === 3; const isRightClick = mouseUpEvent.which === 3;
@ -170,10 +112,11 @@ export default function init({ servicesManager, configuration = {} }) {
csTools.EVENTS.LABELMAP_MODIFIED, csTools.EVENTS.LABELMAP_MODIFIED,
onLabelmapModified onLabelmapModified
); );
//
element.addEventListener(csTools.EVENTS.TOUCH_PRESS, onTouchPress); element.addEventListener(csTools.EVENTS.TOUCH_PRESS, onTouchPress);
element.addEventListener(csTools.EVENTS.MOUSE_CLICK, handleClick); element.addEventListener(csTools.EVENTS.MOUSE_CLICK, handleClick);
element.addEventListener(csTools.EVENTS.TOUCH_START, onTouchStart); element.addEventListener(csTools.EVENTS.TOUCH_START, onTouchStart);
// TODO: This makes scrolling painfully slow // TODO: This makes scrolling painfully slow
// element.addEventListener(cornerstone.EVENTS.NEW_IMAGE, onNewImage); // element.addEventListener(cornerstone.EVENTS.NEW_IMAGE, onNewImage);
} }
@ -197,10 +140,12 @@ export default function init({ servicesManager, configuration = {} }) {
csTools.EVENTS.LABELMAP_MODIFIED, csTools.EVENTS.LABELMAP_MODIFIED,
onLabelmapModified onLabelmapModified
); );
//
element.removeEventListener(csTools.EVENTS.TOUCH_PRESS, onTouchPress); element.removeEventListener(csTools.EVENTS.TOUCH_PRESS, onTouchPress);
element.removeEventListener(csTools.EVENTS.MOUSE_CLICK, handleClick); element.removeEventListener(csTools.EVENTS.MOUSE_CLICK, handleClick);
element.removeEventListener(csTools.EVENTS.TOUCH_START, onTouchStart); element.removeEventListener(csTools.EVENTS.TOUCH_START, onTouchStart);
// TODO: This makes scrolling painfully slow
// element.removeEventListener(cornerstone.EVENTS.NEW_IMAGE, onNewImage); // element.removeEventListener(cornerstone.EVENTS.NEW_IMAGE, onNewImage);
} }

View File

@ -1,144 +0,0 @@
import {
resetLabellingAndContextMenuAction,
setToolContextMenuDataAction,
setLabellingFlowDataAction,
} from './actions.js';
import updateTableWithNewMeasurementData from './updateTableWithNewMeasurementData.js';
const VIEWPORT_INDEX = 0;
function getOnRightClickCallback(store) {
const setToolContextMenuData = (viewportIndex, toolContextMenuData) => {
store.dispatch(resetLabellingAndContextMenuAction());
store.dispatch(
setToolContextMenuDataAction(viewportIndex, toolContextMenuData)
);
};
const getOnCloseCallback = viewportIndex => {
return function onClose() {
const toolContextMenuData = {
visible: false,
};
store.dispatch(
setToolContextMenuDataAction(viewportIndex, toolContextMenuData)
);
};
};
return function onRightClick(event) {
const eventData = event.detail;
const viewportIndex = VIEWPORT_INDEX; // parseInt(eventData.element.dataset.viewportIndex, 10);
const toolContextMenuData = {
eventData,
isTouchEvent: false,
onClose: getOnCloseCallback(viewportIndex),
};
// setToolContextMenuData(viewportIndex, toolContextMenuData);
setToolContextMenuData(0, toolContextMenuData);
};
}
function getOnTouchPressCallback(store) {
const setToolContextMenuData = (viewportIndex, toolContextMenuData) => {
store.dispatch(resetLabellingAndContextMenuAction());
store.dispatch(
setToolContextMenuDataAction(viewportIndex, toolContextMenuData)
);
};
const getOnCloseCallback = viewportIndex => {
return function onClose() {
const toolContextMenuData = {
visible: false,
};
store.dispatch(
setToolContextMenuDataAction(viewportIndex, toolContextMenuData)
);
};
};
return function onTouchPress(event) {
const eventData = event.detail;
const viewportIndex = parseInt(eventData.element.dataset.viewportIndex, 10);
const toolContextMenuData = {
eventData,
isTouchEvent: true,
onClose: getOnCloseCallback(viewportIndex),
};
setToolContextMenuData(viewportIndex, toolContextMenuData);
};
}
function getResetLabellingAndContextMenu(store) {
return function resetLabellingAndContextMenu() {
store.dispatch(resetLabellingAndContextMenuAction());
};
}
/**
*
*
* @param {*} store
* @returns
*/
function getToolLabellingFlowCallback(store) {
const setLabellingFlowData = labellingFlowData => {
store.dispatch(setLabellingFlowDataAction(labellingFlowData));
};
return function toolLabellingFlowCallback(
measurementData,
eventData,
doneCallback,
options = {}
) {
const updateLabelling = ({ location, response, description }) => {
// Update the measurement data with the labelling parameters
if (location) {
measurementData.location = location;
}
measurementData.description = description || '';
if (response) {
measurementData.response = response;
}
updateTableWithNewMeasurementData(measurementData);
};
const labellingDoneCallback = () => {
setLabellingFlowData({ visible: false });
};
const labellingFlowData = {
visible: true,
eventData,
measurementData,
skipAddLabelButton: options.skipAddLabelButton,
editLocation: options.editLocation,
editDescription: options.editDescription,
editResponse: options.editResponse,
editDescriptionOnDialog: options.editDescriptionOnDialog,
labellingDoneCallback,
updateLabelling,
};
setLabellingFlowData(labellingFlowData);
};
}
export {
getToolLabellingFlowCallback,
getOnRightClickCallback,
getOnTouchPressCallback,
getResetLabellingAndContextMenu,
};

View File

@ -1,29 +0,0 @@
import OHIF from '@ohif/core';
import cornerstone from 'cornerstone-core';
export default function updateTableWithNewMeasurementData({
toolType,
measurementNumber,
location,
description,
}) {
// Update all measurements by measurement number
const measurementApi = OHIF.measurements.MeasurementApi.Instance;
const measurements = measurementApi.tools[toolType].filter(
m => m.measurementNumber === measurementNumber
);
measurements.forEach(measurement => {
measurement.location = location;
measurement.description = description;
measurementApi.updateMeasurement(measurement.toolType, measurement);
});
measurementApi.syncMeasurementsAndToolData();
// Update images in all active viewports
cornerstone.getEnabledElements().forEach(enabledElement => {
cornerstone.updateImage(enabledElement.element);
});
}

View File

@ -1,5 +1,5 @@
.editDescriptionDialog { .editDescriptionDialog {
position: absolute; position: relative;
z-index: 300; z-index: 300;
width: 320px; width: 320px;
transition: all 300ms linear; transition: all 300ms linear;

View File

@ -1,24 +1,15 @@
import { Component } from 'react'; import { Component } from 'react';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import SimpleDialog from '../SimpleDialog/SimpleDialog.js'; import SimpleDialog from '../SimpleDialog/SimpleDialog.js';
import bounding from '../../lib/utils/bounding.js';
import { getDialogStyle } from './../Labelling/labellingPositionUtils.js';
import './EditDescriptionDialog.css'; import './EditDescriptionDialog.css';
export default class EditDescriptionDialog extends Component { export default class EditDescriptionDialog extends Component {
static defaultProps = {
componentRef: React.createRef(),
componentStyle: {},
};
static propTypes = { static propTypes = {
description: PropTypes.string,
measurementData: PropTypes.object.isRequired, measurementData: PropTypes.object.isRequired,
onCancel: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired,
componentRef: PropTypes.object,
componentStyle: PropTypes.object,
onUpdate: PropTypes.func.isRequired, onUpdate: PropTypes.func.isRequired,
}; };
@ -28,14 +19,8 @@ export default class EditDescriptionDialog extends Component {
this.state = { this.state = {
description: props.measurementData.description || '', description: props.measurementData.description || '',
}; };
this.mainElement = React.createRef();
} }
componentDidMount = () => {
bounding(this.mainElement);
};
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
if (this.props.description !== prevProps.description) { if (this.props.description !== prevProps.description) {
this.setState({ this.setState({
@ -45,16 +30,12 @@ export default class EditDescriptionDialog extends Component {
} }
render() { render() {
const style = getDialogStyle(this.props.componentStyle);
return ( return (
<SimpleDialog <SimpleDialog
headerTitle="Edit Description" headerTitle="Edit Description"
onClose={this.onClose} onClose={this.onClose}
onConfirm={this.onConfirm} onConfirm={this.onConfirm}
rootClass="editDescriptionDialog" rootClass="editDescriptionDialog"
componentRef={this.mainElement}
componentStyle={style}
> >
<input <input
value={this.state.description} value={this.state.description}

View File

@ -1,20 +1,16 @@
import { Icon, SelectTree } from '@ohif/ui'; import { Icon, SelectTree } from '@ohif/ui';
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types';
import cloneDeep from 'lodash.clonedeep';
import LabellingTransition from './LabellingTransition.js'; import LabellingTransition from './LabellingTransition.js';
import OHIFLabellingData from './OHIFLabellingData.js'; import OHIFLabellingData from './OHIFLabellingData.js';
import PropTypes from 'prop-types';
import bounding from '../../lib/utils/bounding.js';
import cloneDeep from 'lodash.clonedeep';
import { getAddLabelButtonStyle } from './labellingPositionUtils.js';
export default class LabellingFlow extends Component { export default class LabellingFlow extends Component {
static propTypes = { static propTypes = {
eventData: PropTypes.object.isRequired,
measurementData: PropTypes.object.isRequired, measurementData: PropTypes.object.isRequired,
labellingDoneCallback: PropTypes.func.isRequired, labellingDoneCallback: PropTypes.func.isRequired,
updateLabelling: PropTypes.func.isRequired, updateLabelling: PropTypes.func.isRequired,
initialTopDistance: PropTypes.number, initialTopDistance: PropTypes.number,
skipAddLabelButton: PropTypes.bool, skipAddLabelButton: PropTypes.bool,
editLocation: PropTypes.bool, editLocation: PropTypes.bool,
@ -26,11 +22,6 @@ export default class LabellingFlow extends Component {
const { location, locationLabel, description } = props.measurementData; const { location, locationLabel, description } = props.measurementData;
let style = props.componentStyle;
if (!props.skipAddLabelButton) {
style = getAddLabelButtonStyle(props.measurementData, props.eventData);
}
this.state = { this.state = {
location, location,
locationLabel, locationLabel,
@ -38,20 +29,17 @@ export default class LabellingFlow extends Component {
skipAddLabelButton: props.skipAddLabelButton, skipAddLabelButton: props.skipAddLabelButton,
editDescription: props.editDescription, editDescription: props.editDescription,
editLocation: props.editLocation, editLocation: props.editLocation,
componentStyle: style,
confirmationState: false, confirmationState: false,
displayComponent: true, displayComponent: true,
}; };
this.mainElement = React.createRef(); this.mainElement = React.createRef();
this.descriptionInput = React.createRef(); this.descriptionInput = React.createRef();
this.initialItems = OHIFLabellingData; this.initialItems = OHIFLabellingData;
this.currentItems = cloneDeep(this.initialItems); this.currentItems = cloneDeep(this.initialItems);
} }
componentDidUpdate = () => { componentDidUpdate = () => {
this.repositionComponent();
if (this.state.editDescription) { if (this.state.editDescription) {
this.descriptionInput.current.focus(); this.descriptionInput.current.focus();
} }
@ -63,36 +51,14 @@ export default class LabellingFlow extends Component {
mainElementClassName += ' editDescription'; mainElementClassName += ' editDescription';
} }
const style = Object.assign({}, this.state.componentStyle);
if (this.state.skipAddLabelButton) {
if (style.left - 160 < 0) {
style.left = 0;
} else {
style.left -= 160;
}
}
if (this.state.editLocation) {
style.maxHeight = '70vh';
if (!this.initialTopDistance) {
this.initialTopDistance = window.innerHeight - window.innerHeight * 0.3;
style.top = `${this.state.componentStyle.top -
this.initialTopDistance / 2}px`;
} else {
style.top = `${this.state.componentStyle.top}px`;
}
}
return ( return (
<LabellingTransition <LabellingTransition
displayComponent={this.state.displayComponent} displayComponent={this.state.displayComponent}
onTransitionExit={this.props.labellingDoneCallback} onTransitionExit={this.props.labellingDoneCallback}
> >
<> <>
<div className="labellingComponent-overlay"></div>
<div <div
className={mainElementClassName} className={mainElementClassName}
style={style}
ref={this.mainElement} ref={this.mainElement}
onMouseLeave={this.fadeOutAndLeave} onMouseLeave={this.fadeOutAndLeave}
onMouseEnter={this.clearFadeOutTimer} onMouseEnter={this.clearFadeOutTimer}
@ -130,9 +96,8 @@ export default class LabellingFlow extends Component {
<SelectTree <SelectTree
items={this.currentItems} items={this.currentItems}
columns={1} columns={1}
onSelected={this.selectTreeSelectCalback} onSelected={this.selectTreeSelectCallback}
selectTreeFirstTitle="Assign Label" selectTreeFirstTitle="Assign Label"
onComponentChange={this.repositionComponent}
/> />
); );
} else { } else {
@ -195,33 +160,17 @@ export default class LabellingFlow extends Component {
} }
}; };
relabel = event => { relabel = event => this.setState({ editLocation: true });
const viewportTopPosition = this.mainElement.current.offsetParent.offsetTop;
const componentStyle = {
top: event.nativeEvent.y - viewportTopPosition - 55,
left: event.nativeEvent.x,
};
this.setState({
editLocation: true,
componentStyle,
});
};
setDescriptionUpdateMode = () => { setDescriptionUpdateMode = () => {
this.descriptionInput.current.focus(); this.descriptionInput.current.focus();
this.setState({ editDescription: true });
this.setState({
editDescription: true,
});
}; };
descriptionCancel = () => { descriptionCancel = () => {
const { description = '' } = cloneDeep(this.state); const { description = '' } = cloneDeep(this.state);
this.descriptionInput.current.value = description; this.descriptionInput.current.value = description;
this.setState({ editDescription: false });
this.setState({
editDescription: false,
});
}; };
handleKeyPress = e => { handleKeyPress = e => {
@ -240,22 +189,15 @@ export default class LabellingFlow extends Component {
}); });
}; };
selectTreeSelectCalback = (event, itemSelected) => { selectTreeSelectCallback = (event, itemSelected) => {
const location = itemSelected.value; const location = itemSelected.value;
this.props.updateLabelling({ location }); this.props.updateLabelling({ location });
const viewportTopPosition = this.mainElement.current.offsetParent.offsetTop;
const componentStyle = {
top: event.nativeEvent.y - viewportTopPosition - 25,
left: event.nativeEvent.x,
};
this.setState({ this.setState({
editLocation: false, editLocation: false,
confirmationState: true, confirmationState: true,
location: itemSelected.value, location: itemSelected.value,
locationLabel: itemSelected.label, locationLabel: itemSelected.label,
componentStyle,
}); });
if (this.isTouchScreen) { if (this.isTouchScreen) {
@ -276,18 +218,13 @@ export default class LabellingFlow extends Component {
fadeOutAndLeave = () => { fadeOutAndLeave = () => {
// Wait for 1 sec to dismiss the labelling component // Wait for 1 sec to dismiss the labelling component
this.fadeOutTimer = setTimeout(() => { this.fadeOutTimer = setTimeout(
this.setState({ () => this.setState({ displayComponent: false }),
displayComponent: false, 1000
}); );
}, 1000);
}; };
fadeOutAndLeaveFast = () => { fadeOutAndLeaveFast = () => this.setState({ displayComponent: false });
this.setState({
displayComponent: false,
});
};
clearFadeOutTimer = () => { clearFadeOutTimer = () => {
if (!this.fadeOutTimer) { if (!this.fadeOutTimer) {
@ -296,29 +233,4 @@ export default class LabellingFlow extends Component {
clearTimeout(this.fadeOutTimer); clearTimeout(this.fadeOutTimer);
}; };
calculateTopDistance = () => {
const height = window.innerHeight - window.innerHeight * 0.3;
let top = this.state.componentStyle.top - height / 2 + 55;
if (top < 0) {
top = 0;
} else {
if (top + height > window.innerHeight) {
top -= top + height - window.innerHeight;
}
}
return top;
};
repositionComponent = () => {
// SetTimeout for the css animation to end.
setTimeout(() => {
bounding(this.mainElement);
if (this.state.editLocation) {
this.mainElement.current.style.maxHeight = '70vh';
const top = this.calculateTopDistance();
this.mainElement.current.style.top = `${top}px`;
}
}, 200);
};
} }

View File

@ -1,18 +1,9 @@
.labellingComponent-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 10;
background-color: rgba(0, 0, 0, 0.8);
}
.labellingComponent { .labellingComponent {
position: absolute; position: relative;
text-align: center; text-align: center;
z-index: 999; z-index: 999;
transition: all 200ms linear; transition: all 200ms linear;
max-height: 500px;
} }
.labellingComponent .selectedLabel, .labellingComponent .selectedLabel,
@ -87,9 +78,7 @@
border: none; border: none;
} }
.labellingComponent.editDescription .labellingComponent.editDescription .locationDescriptionWrapper #descriptionInput {
.locationDescriptionWrapper
#descriptionInput {
visibility: visible; visibility: visible;
} }

View File

@ -1,21 +1,16 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import cloneDeep from 'lodash.clonedeep'; import cloneDeep from 'lodash.clonedeep';
import EditDescriptionDialog from './../EditDescriptionDialog/EditDescriptionDialog.js'; import EditDescriptionDialog from './../EditDescriptionDialog/EditDescriptionDialog.js';
import LabellingFlow from './LabellingFlow.js'; import LabellingFlow from './LabellingFlow.js';
import './LabellingManager.css'; import './LabellingManager.css';
export default class LabellingManager extends Component { export default class LabellingManager extends Component {
static propTypes = { static propTypes = {
eventData: PropTypes.object.isRequired,
measurementData: PropTypes.object.isRequired, measurementData: PropTypes.object.isRequired,
labellingDoneCallback: PropTypes.func.isRequired, labellingDoneCallback: PropTypes.func.isRequired,
updateLabelling: PropTypes.func.isRequired, updateLabelling: PropTypes.func.isRequired,
skipAddLabelButton: PropTypes.bool, skipAddLabelButton: PropTypes.bool,
editLocation: PropTypes.bool, editLocation: PropTypes.bool,
editDescription: PropTypes.bool, editDescription: PropTypes.bool,
@ -41,7 +36,6 @@ export default class LabellingManager extends Component {
} }
this.state = { this.state = {
componentStyle: getComponentPosition(props.eventData),
skipAddLabelButton: props.skipAddLabelButton, skipAddLabelButton: props.skipAddLabelButton,
editLocation: editLocation, editLocation: editLocation,
editDescription: props.editDescription, editDescription: props.editDescription,
@ -75,20 +69,13 @@ export default class LabellingManager extends Component {
<EditDescriptionDialog <EditDescriptionDialog
onCancel={this.props.labellingDoneCallback} onCancel={this.props.labellingDoneCallback}
onUpdate={this.descriptionDialogUpdate} onUpdate={this.descriptionDialogUpdate}
componentRef={this.editDescriptionDialog}
componentStyle={this.state.componentStyle}
measurementData={measurementData} measurementData={measurementData}
/> />
); );
} }
if (editLocation || editDescription) { if (editLocation || editDescription) {
return ( return <LabellingFlow {...this.props} />;
<LabellingFlow
{...this.props}
componentStyle={this.state.componentStyle}
/>
);
} }
}; };
@ -98,33 +85,19 @@ export default class LabellingManager extends Component {
if (editDescription) { if (editDescription) {
measurementData.description = undefined; measurementData.description = undefined;
} }
if (editLocation) { if (editLocation) {
measurementData.location = undefined; measurementData.location = undefined;
} }
}; };
responseDialogUpdate = response => { responseDialogUpdate = response => {
this.props.updateLabelling({ this.props.updateLabelling({ response });
response,
});
this.props.labellingDoneCallback(); this.props.labellingDoneCallback();
}; };
descriptionDialogUpdate = description => { descriptionDialogUpdate = description => {
this.props.updateLabelling({ this.props.updateLabelling({ description });
description,
});
this.props.labellingDoneCallback(); this.props.labellingDoneCallback();
}; };
} }
function getComponentPosition(eventData) {
const {
event: { clientX: left, clientY: top },
} = eventData;
return {
left,
top,
};
}

View File

@ -1,53 +0,0 @@
import cornerstone from 'cornerstone-core';
const buttonSize = {
width: 96,
height: 28,
};
export function getAddLabelButtonStyle(measurementData, eventData) {
const { start, end } = measurementData.handles;
const { client } = eventData.currentPoints;
const clientStart = cornerstone.pixelToCanvas(eventData.element, start);
const clientEnd = cornerstone.pixelToCanvas(eventData.element, end);
const canvasOffSetLeft = client.x - clientStart.x;
const canvasOffSetTop = client.y - clientStart.y;
const position = {
left: clientEnd.x + canvasOffSetLeft,
top: clientEnd.y + canvasOffSetTop,
};
if (start.y > end.y) {
position.top -= buttonSize.height;
}
if (start.x > end.x) {
position.left -= buttonSize.width;
}
return position;
}
export function getDialogStyle(componentStyle) {
const style = Object.assign({}, componentStyle);
const dialogProps = {
width: 320,
height: 230,
};
// Get max values to avoid position out of the screen
const maxLeft = window.innerWidth - dialogProps.width;
const maxTop = window.innerHeight - dialogProps.height;
// Positioning the dialog with its center on the click event
style.left -= dialogProps.width / 2;
style.top -= dialogProps.height / 2;
if (style.left > maxLeft) {
style.left = maxLeft;
}
if (style.top > maxTop) {
style.top = maxTop;
}
return style;
}

View File

@ -1,24 +0,0 @@
import { connect } from 'react-redux';
import LabellingOverlay from './LabellingOverlay';
const mapStateToProps = state => {
if (!state.ui || !state.ui.labelling) {
return {
visible: false,
};
}
const labellingFlowData = state.ui.labelling;
return {
visible: false,
...labellingFlowData,
};
};
const ConnectedLabellingOverlay = connect(
mapStateToProps,
null
)(LabellingOverlay);
export default ConnectedLabellingOverlay;

View File

@ -1,24 +0,0 @@
import { connect } from 'react-redux';
import ToolContextMenu from './ToolContextMenu';
const mapStateToProps = (state, ownProps) => {
if (!state.ui || !state.ui.contextMenu) {
return {
visible: false,
};
}
const { viewportIndex } = ownProps;
const toolContextMenuData = state.ui.contextMenu[viewportIndex];
return {
...toolContextMenuData,
};
};
const ConnectedToolContextMenu = connect(
mapStateToProps,
null
)(ToolContextMenu);
export default ConnectedToolContextMenu;

View File

@ -35,7 +35,10 @@ const mapDispatchToProps = (dispatch, ownProps) => {
// set new language // set new language
i18n.changeLanguage(language); i18n.changeLanguage(language);
ownProps.hide(); if (ownProps.hide) {
ownProps.hide();
}
dispatch( dispatch(
setUserPreferences({ setUserPreferences({
windowLevelData, windowLevelData,

View File

@ -1,23 +0,0 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import LabellingManager from '../components/Labelling/LabellingManager';
class LabellingOverlay extends Component {
static propTypes = {
visible: PropTypes.bool.isRequired,
};
static defaultProps = {
visible: false,
};
render() {
if (!this.props.visible) {
return null;
}
return <LabellingManager {...this.props} />;
}
}
export default LabellingOverlay;

View File

@ -1,5 +1,5 @@
.ToolContextMenu { .ToolContextMenu {
position: absolute; position: relative;
background-color: white; background-color: white;
border: 1px solid white; border: 1px solid white;
border-radius: 5px; border-radius: 5px;

View File

@ -1,9 +1,6 @@
import React, { Component } from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import cornerstone from 'cornerstone-core'; import { commandsManager } from './../App.js';
import cornerstoneTools from 'cornerstone-tools';
// This whole component should live in the Measurements Extension :thinking:
import getMeasurementLocationCallback from '../appExtensions/MeasurementsPanel/getMeasurementLocationCallback';
import './ToolContextMenu.css'; import './ToolContextMenu.css';
@ -17,232 +14,119 @@ const toolTypes = [
'RectangleRoi', 'RectangleRoi',
]; ];
let defaultDropdownItems = [ const ToolContextMenu = ({
{ onSetLabel,
actionType: 'Delete', onSetDescription,
action: ({ nearbyToolData, eventData }) => { isTouchEvent,
const element = eventData.element; eventData,
onClose,
cornerstoneTools.removeToolState( onDelete,
element, }) => {
nearbyToolData.toolType, const defaultDropdownItems = [
nearbyToolData.tool {
); actionType: 'Delete',
cornerstone.updateImage(element); action: ({ nearbyToolData, eventData }) =>
onDelete(nearbyToolData, eventData),
}, },
}, {
{ actionType: 'setLabel',
actionType: 'setLabel', action: ({ nearbyToolData, eventData }) => {
action: ({ nearbyToolData, eventData }) => { const { tool: measurementData } = nearbyToolData;
const { tool } = nearbyToolData; onSetLabel(eventData, measurementData);
},
const options = {
skipAddLabelButton: true,
editLocation: true,
};
getMeasurementLocationCallback(eventData, tool, options);
}, },
}, {
{ actionType: 'setDescription',
actionType: 'setDescription', action: ({ nearbyToolData, eventData }) => {
action: ({ nearbyToolData, eventData }) => { const { tool: measurementData } = nearbyToolData;
const { tool } = nearbyToolData; onSetDescription(eventData, measurementData);
},
const options = {
editDescriptionOnDialog: true,
};
getMeasurementLocationCallback(eventData, tool, options);
}, },
}, ];
];
function getNearbyToolData(element, coords, toolTypes) { const getDropdownItems = (eventData, isTouchEvent = false) => {
const nearbyTool = {}; const nearbyToolData = commandsManager.runCommand('getNearbyToolData', {
let pointNearTool = false; element: eventData.element,
canvasCoordinates: eventData.currentPoints.canvas,
availableToolTypes: toolTypes,
});
toolTypes.forEach(toolType => { // Annotate tools for touch events already have a press handle to edit it, has a better UX for deleting it
const toolData = cornerstoneTools.getToolState(element, toolType); if (
if (!toolData) { isTouchEvent &&
nearbyToolData &&
nearbyToolData.toolType === 'arrowAnnotate'
) {
return; return;
} }
toolData.data.forEach(function(data, index) { let dropdownItems = [];
// TODO: Fix this, it's ugly if (nearbyToolData) {
let toolInterface = cornerstoneTools.getToolForElement(element, toolType); defaultDropdownItems.forEach(item => {
if (!toolInterface) { item.params = {
toolInterface = cornerstoneTools.getToolForElement( eventData,
element, nearbyToolData,
`${toolType}Tool` };
);
}
if (!toolInterface) { if (item.actionType === 'Delete') {
throw new Error('Tool not found.'); item.text = 'Delete measurement';
}
if (toolInterface.pointNearTool(element, data, coords)) {
pointNearTool = true;
nearbyTool.tool = data;
nearbyTool.index = index;
nearbyTool.toolType = toolType;
}
});
if (pointNearTool) {
return false;
}
});
return pointNearTool ? nearbyTool : undefined;
}
function getDropdownItems(eventData, isTouchEvent = false) {
const nearbyToolData = getNearbyToolData(
eventData.element,
eventData.currentPoints.canvas,
toolTypes
);
// Annotate tools for touch events already have a press handle to edit it, has a better UX for deleting it
if (
isTouchEvent &&
nearbyToolData &&
nearbyToolData.toolType === 'arrowAnnotate'
) {
return;
}
let dropdownItems = [];
if (nearbyToolData) {
defaultDropdownItems.forEach(function(item) {
item.params = {
eventData,
nearbyToolData,
};
if (item.actionType === 'Delete') {
item.text = 'Delete measurement';
}
if (item.actionType === 'setLabel') {
item.text = 'Relabel';
}
if (item.actionType === 'setDescription') {
item.text = `${
nearbyToolData.tool.description ? 'Edit' : 'Add'
} Description`;
}
dropdownItems.push(item);
});
}
return dropdownItems;
}
class ToolContextMenu extends Component {
static propTypes = {
isTouchEvent: PropTypes.bool.isRequired,
eventData: PropTypes.object,
onClose: PropTypes.func,
visible: PropTypes.bool.isRequired,
};
static defaultProps = {
visible: true,
isTouchEvent: false,
};
constructor(props) {
super(props);
this.mainElement = React.createRef();
}
render() {
if (!this.props.eventData) {
return null;
}
const { isTouchEvent, eventData } = this.props;
const dropdownItems = getDropdownItems(eventData, isTouchEvent);
// Skip if there is no dropdown item
if (!dropdownItems.length) {
return '';
}
const dropdownComponents = dropdownItems.map(item => {
const itemOnClick = event => {
item.action(item.params);
if (this.props.onClose) {
this.props.onClose();
} }
};
return ( if (item.actionType === 'setLabel') {
<li key={item.actionType}> item.text = 'Relabel';
<button className="form-action" onClick={itemOnClick}> }
<span key={item.actionType}>{item.text}</span>
</button>
</li>
);
});
const position = { if (item.actionType === 'setDescription') {
top: `${eventData.currentPoints.canvas.y}px`, item.text = `${
left: `${eventData.currentPoints.canvas.x}px`, nearbyToolData.tool.description ? 'Edit' : 'Add'
}; } Description`;
}
return ( dropdownItems.push(item);
<div className="ToolContextMenu" style={position} ref={this.mainElement}> });
<ul className="bounded">{dropdownComponents}</ul> }
return dropdownItems;
};
const itemOnClickHandler = (action, params, onClose) => {
action(params);
if (onClose) {
onClose();
}
};
const dropdownItems = getDropdownItems(eventData, isTouchEvent);
return (
dropdownItems.length &&
eventData && (
<div className="ToolContextMenu">
<ul className="bounded">
{dropdownItems.map(({ params, action, text, actionType }) => (
<li key={actionType}>
<button
className="form-action"
onClick={() => itemOnClickHandler(action, params, onClose)}
>
<span key={actionType}>{text}</span>
</button>
</li>
))}
</ul>
</div> </div>
); )
} );
};
componentDidMount = () => { ToolContextMenu.propTypes = {
if (this.mainElement.current) { isTouchEvent: PropTypes.bool.isRequired,
this.updateElementPosition(); eventData: PropTypes.object,
} onClose: PropTypes.func,
}; };
componentDidUpdate = () => { ToolContextMenu.defaultProps = {
if (this.mainElement.current) { isTouchEvent: false,
this.updateElementPosition(); };
}
};
updateElementPosition = () => {
const {
offsetParent,
offsetTop,
offsetHeight,
offsetWidth,
offsetLeft,
} = this.mainElement.current;
const { eventData } = this.props;
if (offsetTop + offsetHeight > offsetParent.offsetHeight) {
const offBoundPixels =
offsetTop + offsetHeight - offsetParent.offsetHeight;
const top = eventData.currentPoints.canvas.y - offBoundPixels;
this.mainElement.current.style.top = `${top > 0 ? top : 0}px`;
}
if (offsetLeft + offsetWidth > offsetParent.offsetWidth) {
const offBoundPixels =
offsetLeft + offsetWidth - offsetParent.offsetWidth;
const left = eventData.currentPoints.canvas.x - offBoundPixels;
this.mainElement.current.style.left = `${left > 0 ? left : 0}px`;
}
};
}
export default ToolContextMenu; export default ToolContextMenu;

View File

@ -7,7 +7,6 @@ import OHIF from '@ohif/core';
import moment from 'moment'; import moment from 'moment';
import ConnectedHeader from './ConnectedHeader.js'; import ConnectedHeader from './ConnectedHeader.js';
import ConnectedToolbarRow from './ConnectedToolbarRow.js'; import ConnectedToolbarRow from './ConnectedToolbarRow.js';
import ConnectedLabellingOverlay from './ConnectedLabellingOverlay';
import ConnectedStudyBrowser from './ConnectedStudyBrowser.js'; import ConnectedStudyBrowser from './ConnectedStudyBrowser.js';
import ConnectedViewerMain from './ConnectedViewerMain.js'; import ConnectedViewerMain from './ConnectedViewerMain.js';
import SidePanel from './../components/SidePanel.js'; import SidePanel from './../components/SidePanel.js';
@ -317,7 +316,6 @@ class Viewer extends Component {
)} )}
</SidePanel> </SidePanel>
</div> </div>
<ConnectedLabellingOverlay />
</> </>
); );
} }

View File

@ -2,7 +2,6 @@ import './ViewerMain.css';
import { Component } from 'react'; import { Component } from 'react';
import { ConnectedViewportGrid } from './../components/ViewportGrid/index.js'; import { ConnectedViewportGrid } from './../components/ViewportGrid/index.js';
import ConnectedToolContextMenu from './ConnectedToolContextMenu.js';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
@ -153,7 +152,6 @@ class ViewerMain extends Component {
setViewportData={this.setViewportData} setViewportData={this.setViewportData}
> >
{/* Children to add to each viewport that support children */} {/* Children to add to each viewport that support children */}
<ConnectedToolContextMenu />
</ConnectedViewportGrid> </ConnectedViewportGrid>
)} )}
</div> </div>

View File

@ -7,7 +7,7 @@ import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom';
import App from './App.js'; import App from './App.js';
function installViewer(props, containerId = 'root', callback) { function installViewer(config, containerId = 'root', callback) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (!container) { if (!container) {
@ -16,7 +16,7 @@ function installViewer(props, containerId = 'root', callback) {
); );
} }
return ReactDOM.render(<App {...props} />, container, callback); return ReactDOM.render(<App config={config} />, container, callback);
} }
export { App, installViewer }; export { App, installViewer };

View File

@ -18,35 +18,36 @@ import ReactDOM from 'react-dom';
* "baked in" to the published application. * "baked in" to the published application.
* *
* Depending on your use case/needs, you may want to consider not adding any extensions * Depending on your use case/needs, you may want to consider not adding any extensions
* by default HERE, and instead provide them via the configuration specified at * by default HERE, and instead provide them via the extensions configuration key or
* `window.config.extensions`, or by using the exported `App` component, and passing * by using the exported `App` component, and passing in your extensions as props using
* in your extensions as props. * the defaultExtensions property.
*/ */
import OHIFVTKExtension from '@ohif/extension-vtk'; import OHIFVTKExtension from '@ohif/extension-vtk';
import OHIFDicomHtmlExtension from '@ohif/extension-dicom-html'; import OHIFDicomHtmlExtension from '@ohif/extension-dicom-html';
import OHIFDicomMicroscopyExtension from '@ohif/extension-dicom-microscopy'; import OHIFDicomMicroscopyExtension from '@ohif/extension-dicom-microscopy';
import OHIFDicomPDFExtension from '@ohif/extension-dicom-pdf'; import OHIFDicomPDFExtension from '@ohif/extension-dicom-pdf';
// Default Settings /*
* Default Settings
*/
let config = {}; let config = {};
const appDefaults = {
routerBasename: '/',
};
if (window) { if (window) {
config = window.config || {}; config = window.config || {};
config.extensions = [ }
const appProps = {
config,
defaultExtensions: [
OHIFVTKExtension, OHIFVTKExtension,
OHIFDicomHtmlExtension, OHIFDicomHtmlExtension,
OHIFDicomMicroscopyExtension, OHIFDicomMicroscopyExtension,
OHIFDicomPDFExtension, OHIFDicomPDFExtension,
]; ],
} };
const appProps = Object.assign({}, appDefaults, config); /** Create App */
// Create App
const app = React.createElement(App, appProps, null); const app = React.createElement(App, appProps, null);
// Render /** Render */
ReactDOM.render(app, document.getElementById('root')); ReactDOM.render(app, document.getElementById('root'));

View File

@ -6,18 +6,16 @@ import {
} from 'redux/es/redux.js'; } from 'redux/es/redux.js';
// import { createLogger } from 'redux-logger'; // import { createLogger } from 'redux-logger';
import layoutReducers from './layout/reducers.js';
import { reducer as oidcReducer } from 'redux-oidc'; import { reducer as oidcReducer } from 'redux-oidc';
import { redux } from '@ohif/core'; import { redux } from '@ohif/core';
import thunkMiddleware from 'redux-thunk'; import thunkMiddleware from 'redux-thunk';
// Combine our @ohif/core, ui, and oidc reducers // Combine our @ohif/core and oidc reducers
// Set init data, using values found in localStorage // Set init data, using values found in localStorage
const { reducers, localStorage, sessionStorage } = redux; const { reducers, localStorage, sessionStorage } = redux;
const middleware = [thunkMiddleware]; const middleware = [thunkMiddleware];
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose;
reducers.ui = layoutReducers;
reducers.oidc = oidcReducer; reducers.oidc = oidcReducer;
const rootReducer = combineReducers(reducers); const rootReducer = combineReducers(reducers);

View File

@ -1,33 +0,0 @@
const defaultState = {
labelling: {},
contextMenu: {},
};
const ui = (state = defaultState, action) => {
switch (action.type) {
case 'SET_LABELLING_FLOW_DATA': {
const labelling = Object.assign({}, action.labellingFlowData);
return Object.assign({}, state, { labelling });
}
case 'SET_TOOL_CONTEXT_MENU_DATA': {
const contextMenu = Object.assign({}, state.contextMenu);
contextMenu[action.viewportIndex] = Object.assign(
{},
action.toolContextMenuData
);
return Object.assign({}, state, { contextMenu });
}
case 'RESET_LABELLING_AND_CONTEXT_MENU':
return Object.assign({}, state, {
labelling: defaultState.labelling,
contextMenu: defaultState.contextMenu,
});
default:
return state;
}
};
export default ui;

View File

@ -12246,7 +12246,7 @@ lodash.memoize@^4.1.2:
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=
lodash.merge@^4.4.0, lodash.merge@^4.6.1: lodash.merge@^4.4.0, lodash.merge@^4.6.1, lodash.merge@^4.6.2:
version "4.6.2" version "4.6.2"
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a" resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==