diff --git a/package.json b/package.json index 2c99fbe31..b42c7107c 100644 --- a/package.json +++ b/package.json @@ -73,7 +73,7 @@ "hammerjs": "^2.0.8", "lodash.isequal": "4.5.0", "moment": "^2.24.0", - "ohif-core": "0.4.1", + "ohif-core": "0.4.2", "ohif-cornerstone-extension": "^0.0.28", "ohif-dicom-html-extension": "^0.0.2", "ohif-dicom-microscopy-extension": "^0.0.5", diff --git a/public/icons.svg b/public/icons.svg index e8abd6eb4..3b0690b4f 100644 --- a/public/icons.svg +++ b/public/icons.svg @@ -237,4 +237,10 @@ + + + + + + diff --git a/src/App.js b/src/App.js index e9dff75ce..9ec2fa3d7 100644 --- a/src/App.js +++ b/src/App.js @@ -19,6 +19,8 @@ import { getUserManagerForOpenIdConnectClient, initWebWorkers, } from './utils/index.js' +import setupTools from './setupTools' +import ConnectedToolContextMenu from './connectedComponents/ConnectedToolContextMenu' const { ExtensionManager } = OHIF.extensions const { reducers, localStorage } = OHIF.redux @@ -35,31 +37,15 @@ store.subscribe(() => { }) }) -const availableTools = [ - { name: 'Pan', mouseButtonMasks: [1, 4] }, - { name: 'Zoom', mouseButtonMasks: [1, 2] }, - { name: 'Wwwc', mouseButtonMasks: [1] }, - { name: 'Bidirectional', mouseButtonMasks: [1] }, - { name: 'Length', mouseButtonMasks: [1] }, - { name: 'Angle', mouseButtonMasks: [1] }, - { name: 'StackScroll', mouseButtonMasks: [1] }, - { name: 'Brush', mouseButtonMasks: [1] }, - { name: 'FreehandMouse', mouseButtonMasks: [1] }, - { name: 'PanMultiTouch' }, - { name: 'ZoomTouchPinch' }, - { name: 'StackScrollMouseWheel' }, - { name: 'StackScrollMultiTouch' }, -] +setupTools(store) -const toolAction = OHIF.redux.actions.setExtensionData('cornerstone', { - availableTools, -}) - -store.dispatch(toolAction) +const children = { + viewport: [], +} /** TODO: extensions should be passed in as prop as soon as we have the extensions as separate packages and then registered by ExtensionsManager */ const extensions = [ - new OHIFCornerstoneExtension({}), + new OHIFCornerstoneExtension({ children }), new OHIFVTKExtension(), new OHIFDicomPDFExtension(), new OHIFDicomHtmlExtension(), diff --git a/src/components/EditDescriptionDialog/EditDescriptionDialog.css b/src/components/EditDescriptionDialog/EditDescriptionDialog.css new file mode 100644 index 000000000..9d3de80fd --- /dev/null +++ b/src/components/EditDescriptionDialog/EditDescriptionDialog.css @@ -0,0 +1,6 @@ +.editDescriptionDialog { + position: absolute; + z-index: 300; + width: 320px; + transition: all 300ms linear; +} diff --git a/src/components/EditDescriptionDialog/EditDescriptionDialog.js b/src/components/EditDescriptionDialog/EditDescriptionDialog.js new file mode 100644 index 000000000..a90fcfa19 --- /dev/null +++ b/src/components/EditDescriptionDialog/EditDescriptionDialog.js @@ -0,0 +1,82 @@ +import { Component } from 'react' +import React from 'react' +import PropTypes from 'prop-types' +import SimpleDialog from '../SimpleDialog/SimpleDialog.js' + +import bounding from '../../lib/utils/bounding.js' +import { getDialogStyle } from './../Labelling/labellingPositionUtils.js' + +import './EditDescriptionDialog.css' + +export default class EditDescriptionDialog extends Component { + static defaultProps = { + componentRef: React.createRef(), + componentStyle: {}, + } + + static propTypes = { + measurementData: PropTypes.object.isRequired, + onCancel: PropTypes.func.isRequired, + componentRef: PropTypes.object, + componentStyle: PropTypes.object, + onUpdate: PropTypes.func.isRequired, + } + + constructor(props) { + super(props) + + this.state = { + description: props.measurementData.description || '', + } + + this.mainElement = React.createRef() + } + + componentDidMount = () => { + bounding(this.mainElement) + } + + componentDidUpdate(prevProps) { + if (this.props.description !== prevProps.description) { + this.setState({ + description: this.props.description, + }) + } + } + + render() { + const style = getDialogStyle(this.props.componentStyle) + + return ( + + + + ) + } + + onClose = () => { + this.props.onCancel() + } + + onConfirm = () => { + this.props.onUpdate(this.state.description) + } + + handleChange = event => { + this.setState({ description: event.target.value }) + } +} diff --git a/src/components/Labelling/LabellingFlow.js b/src/components/Labelling/LabellingFlow.js new file mode 100644 index 000000000..0f6185d7c --- /dev/null +++ b/src/components/Labelling/LabellingFlow.js @@ -0,0 +1,276 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +import cloneDeep from 'lodash.clonedeep' +import bounding from '../../lib/utils/bounding.js' + +import LabellingTransition from './LabellingTransition.js' +import { SelectTree } from 'react-viewerbase' +import { getAddLabelButtonStyle } from './labellingPositionUtils.js' + +import OHIFLabellingData from './OHIFLabellingData.js' + +export default class LabellingFlow extends Component { + static propTypes = { + eventData: PropTypes.object.isRequired, + measurementData: PropTypes.object.isRequired, + + labellingDoneCallback: PropTypes.func.isRequired, + updateLabelling: PropTypes.func.isRequired, + + skipAddLabelButton: PropTypes.bool, + editLocation: PropTypes.bool, + editDescription: PropTypes.bool, + } + + constructor(props) { + super(props) + + const { location, locationLabel, description } = props.measurementData + + let style = props.componentStyle + if (!props.skipAddLabelButton) { + style = getAddLabelButtonStyle(props.measurementData, props.eventData) + } + + this.state = { + location, + locationLabel, + description, + skipAddLabelButton: props.skipAddLabelButton, + editDescription: props.editDescription, + editLocation: props.editLocation, + componentStyle: style, + confirmationState: false, + displayComponent: true, + } + + this.mainElement = React.createRef() + this.descriptionInput = React.createRef() + + this.initialItems = OHIFLabellingData + this.currentItems = cloneDeep(this.initialItems) + } + + componentDidUpdate = () => { + this.repositionComponent() + } + + render() { + let mainElementClassName = 'labellingComponent' + if (this.state.editDescription) { + mainElementClassName += ' editDescription' + } + + const style = Object.assign({}, this.state.componentStyle) + if (this.state.skipAddLabelButton) { + style.left -= 160 + } + + return ( + +
+ {this.labellingStateFragment()} +
+
+ ) + } + + labellingStateFragment = () => { + const { + skipAddLabelButton, + editLocation, + description, + locationLabel, + } = this.state + + if (!skipAddLabelButton) { + return ( + <> + + + ) + } else { + if (editLocation) { + return ( + + ) + } else { + return ( + <> +
+ + + +
+
+
{locationLabel}
+
+ +
+
+
+ + +
+
+ + +
+ + ) + } + } + } + + relabel = () => { + this.setState({ + editLocation: true, + }) + } + + setDescriptionUpdateMode = () => { + this.descriptionInput.current.focus() + + this.setState({ + editDescription: true, + }) + } + + descriptionCancel = () => { + const { description = '' } = cloneDeep(this.state) + this.descriptionInput.current.value = description + + this.setState({ + editDescription: false, + }) + } + + descriptionSave = () => { + const description = this.descriptionInput.current.value + this.props.updateLabelling({ description }) + + this.setState({ + description, + editDescription: false, + }) + } + + selectTreeSelectCalback = (event, itemSelected) => { + const location = itemSelected.value + this.props.updateLabelling({ location }) + + const viewportTopPosition = this.mainElement.current.offsetParent.offsetTop + const componentStyle = { + top: event.nativeEvent.y - viewportTopPosition - 25, + left: this.state.componentStyle.left, + } + + this.setState({ + editLocation: false, + confirmationState: true, + location: itemSelected.value, + locationLabel: itemSelected.label, + componentStyle, + }) + + if (this.isTouchScreen) { + this.setTimeout = setTimeout(() => { + this.setState({ + displayComponent: false, + }) + }, 2000) + } + } + + showLabelling = () => { + this.setState({ + skipAddLabelButton: true, + editLocation: false, + }) + } + + fadeOutAndLeave = () => { + // Wait for 1 sec to dismiss the labelling component + this.fadeOutTimer = setTimeout(() => { + this.setState({ + displayComponent: false, + }) + }, 1000) + } + + fadeOutAndLeaveFast = () => { + this.setState({ + displayComponent: false, + }) + } + + clearFadeOutTimer = () => { + if (!this.fadeOutTimer) { + return + } + + clearTimeout(this.fadeOutTimer) + } + + repositionComponent = () => { + // SetTimeout for the css animation to end. + setTimeout(() => { + bounding(this.mainElement) + }, 200) + } +} diff --git a/src/components/Labelling/LabellingManager.css b/src/components/Labelling/LabellingManager.css new file mode 100644 index 000000000..5a14e1cb4 --- /dev/null +++ b/src/components/Labelling/LabellingManager.css @@ -0,0 +1,130 @@ +.labellingComponent { + position: absolute; + text-align: center; + z-index: 300; + transition: all 200ms linear; +} + +.labellingComponent .selectedLabel, +.labellingComponent .selectedDescription { + padding: 5px; + background-color: white; + width: 150px; +} + +.labellingComponent .addLabelButton { + color: #000000; + background-color: #20a5d6; + border: 2px solid #44626f; + border-radius: 14px; + cursor: pointer; + font-weight: bold; + font-size: 13px; + line-height: 24px; + opacity: 1; + padding: 0 14px; + transition: opacity 0.3s ease; + outline: none; + cursor: pointer; +} + +.labellingComponent .commonButton { + border: 1px solid #44626f; + color: #ffffff; + background-color: #000000; + border-radius: 16px; + font-weight: bold; + font-size: 13px; + line-height: 26px; + padding: 0 12px; + margin: 10px 5px 0 0; + outline: none; + cursor: pointer; +} + +.labellingComponent .locationDescriptionWrapper { + background-color: #ffffff; + line-height: 46px; + height: 46px; + font-size: 13px; + position: relative; + width: 290px; + min-width: 260px; + padding: 0 12px; + margin: 0 auto; + display: inline-block; +} + +.labellingComponent .locationDescriptionWrapper .location { + transition: all 300ms linear; + position: absolute; + white-space: nowrap; + bottom: 0; +} + +.labellingComponent.editDescription .locationDescriptionWrapper .location { + bottom: 38px; +} + +.labellingComponent .locationDescriptionWrapper #descriptionInput { + transition-delay: all 300ms linear; + visibility: hidden; + outline: none; + height: 46px; + width: 100%; + line-height: 20px; + font-size: 13px; + border: none; +} + +.labellingComponent.editDescription + .locationDescriptionWrapper + #descriptionInput { + visibility: visible; +} + +.labellingComponent.editDescription .location { + color: #337ab7; +} + +.labellingComponent .commonButtons, +.labellingComponent.editDescription .editDescriptionButtons { + display: block; + margin-left: 55px; +} + +.labellingComponent.editDescription .commonButtons, +.labellingComponent .editDescriptionButtons { + display: none; +} + +.labellingComponent .commonButtons { + text-align: center; + margin-left: 55px; +} + +.labellingComponent .commonButton.left { + float: left; +} + +.labellingComponent .commonButton.right { + float: right; +} + +.labellingComponent .checkIconWrapper { + display: inline-block; + background-color: #337ab7; + border-radius: 46px; + width: 46px; + height: 46px; + margin-right: 10px; + vertical-align: bottom; + cursor: pointer; +} + +.labellingComponent .checkIcon { + width: 20px; + height: 20px; + margin: 13px; + fill: black; +} diff --git a/src/components/Labelling/LabellingManager.js b/src/components/Labelling/LabellingManager.js new file mode 100644 index 000000000..e293202ce --- /dev/null +++ b/src/components/Labelling/LabellingManager.js @@ -0,0 +1,130 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' + +import cloneDeep from 'lodash.clonedeep' + +import EditDescriptionDialog from './../EditDescriptionDialog/EditDescriptionDialog.js' +import LabellingFlow from './LabellingFlow.js' + +import './LabellingManager.css' + +export default class LabellingManager extends Component { + static propTypes = { + eventData: PropTypes.object.isRequired, + measurementData: PropTypes.object.isRequired, + + labellingDoneCallback: PropTypes.func.isRequired, + updateLabelling: PropTypes.func.isRequired, + + skipAddLabelButton: PropTypes.bool, + editLocation: PropTypes.bool, + editDescription: PropTypes.bool, + editDescriptionOnDialog: PropTypes.bool, + } + + static defaultProps = { + skipAddLabelButton: false, + editLocation: false, + editDescription: false, + editDescriptionOnDialog: false, + } + + constructor(props) { + super(props) + + const measurementData = cloneDeep(props.measurementData) + this.treatMeasurementData(measurementData) + + let editLocation = props.editLocation + if (!props.editDescription && !props.editLocation) { + editLocation = true + } + + this.state = { + componentStyle: getComponentPosition(props.eventData), + skipAddLabelButton: props.skipAddLabelButton, + editLocation: editLocation, + editDescription: props.editDescription, + editDescriptionOnDialog: props.editDescriptionOnDialog, + measurementData: measurementData, + } + } + + componentDidMount = () => { + document.addEventListener('touchstart', this.onTouchStart) + } + + componentWillUnmount = () => { + document.removeEventListener('touchstart', this.onTouchStart) + } + + render() { + return this.getRenderComponent() + } + + getRenderComponent = () => { + const { + editLocation, + editDescription, + editDescriptionOnDialog, + measurementData, + } = this.state + + if (editDescriptionOnDialog) { + return ( + + ) + } + + if (editLocation || editDescription) { + return ( + + ) + } + } + + treatMeasurementData = measurementData => { + const { editDescription, editLocation } = this.props + + if (editDescription) { + measurementData.description = undefined + } + if (editLocation) { + measurementData.location = undefined + } + } + + responseDialogUpdate = response => { + this.props.updateLabelling({ + response, + }) + this.props.labellingDoneCallback() + } + + descriptionDialogUpdate = description => { + this.props.updateLabelling({ + description, + }) + this.props.labellingDoneCallback() + } +} + +function getComponentPosition(eventData) { + const { + event: { clientX: left, clientY: top }, + } = eventData + + return { + left, + top, + } +} diff --git a/src/components/Labelling/LabellingTransition.css b/src/components/Labelling/LabellingTransition.css new file mode 100644 index 000000000..bb46bd9b9 --- /dev/null +++ b/src/components/Labelling/LabellingTransition.css @@ -0,0 +1,21 @@ +.labelling-appear { + opacity: 0; +} + +.labelling-appear.labelling-appear-active { + opacity: 1; + transition: opacity 500ms linear; +} + +.labelling-exit { + opacity: 1; +} + +.labelling-exit.labelling-exit-active { + opacity: 0; + transition: opacity 500ms linear; +} + +.labelling-exit-done { + opacity: 0; +} diff --git a/src/components/Labelling/LabellingTransition.js b/src/components/Labelling/LabellingTransition.js new file mode 100644 index 000000000..dbba1702d --- /dev/null +++ b/src/components/Labelling/LabellingTransition.js @@ -0,0 +1,31 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import { CSSTransition } from 'react-transition-group' + +import './LabellingTransition.css' + +// If these variables changes, CSS must be updated +const transitionDuration = 500 +const transitionClassName = 'labelling' +const transitionOnAppear = true + +export default class LabellingTransition extends Component { + static propTypes = { + children: PropTypes.node.isRequired, + displayComponent: PropTypes.bool.isRequired, + onTransitionExit: PropTypes.func.isRequired, + } + render() { + return ( + + {this.props.children} + + ) + } +} diff --git a/src/components/Labelling/OHIFLabellingData.js b/src/components/Labelling/OHIFLabellingData.js new file mode 100644 index 000000000..060caa78a --- /dev/null +++ b/src/components/Labelling/OHIFLabellingData.js @@ -0,0 +1,39 @@ +const items = [ + 'Abdomen/Chest Wall', + 'Adrenal', + 'Bladder', + 'Bone', + 'Brain', + 'Breast', + 'Colon', + 'Esophagus', + 'Extremities', + 'Gallbladder', + 'Kidney', + 'Liver', + 'Lung', + 'Lymph Node', + 'Mediastinum/Hilum', + 'Muscle', + 'Neck', + 'Other Soft Tissue', + 'Ovary', + 'Pancreas', + 'Pelvis', + 'Peritoneum/Omentum', + 'Prostate', + 'Retroperitoneum', + 'Small Bowel', + 'Spleen', + 'Stomach', + 'Subcutaneous', +] + +const OHIFLabellingData = items.map(item => { + return { + label: item, + value: item, + } +}) + +export default OHIFLabellingData diff --git a/src/components/Labelling/labellingPositionUtils.js b/src/components/Labelling/labellingPositionUtils.js new file mode 100644 index 000000000..50fdc8e0a --- /dev/null +++ b/src/components/Labelling/labellingPositionUtils.js @@ -0,0 +1,53 @@ +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 +} diff --git a/src/components/SimpleDialog/SimpleDialog.css b/src/components/SimpleDialog/SimpleDialog.css new file mode 100644 index 000000000..a40b6ab17 --- /dev/null +++ b/src/components/SimpleDialog/SimpleDialog.css @@ -0,0 +1,133 @@ +.simpleDialog { + position: fixed; + border: 0; + border-radius: 6px; + background-color: #151a1f; +} +.simpleDialog .header { + border-bottom-width: 3px; + border-bottom-style: solid; + border-bottom-color: #000; + padding: 19px 22px 17px; + position: relative; +} + +.simpleDialog .header .title { + font-size: 20px; + font-weight: 600; + line-height: 24px; + padding-right: 40px; + color: #91b9cd; + margin: 0; +} + +.simpleDialog .header .closeBtn { + height: 20px; + opacity: 1; + overflow: hidden; + padding: 2px; + text-align: center; + text-shadow: none; + width: 20px; + color: #91b9cd; + cursor: pointer; + position: absolute; + right: 21px; + top: 50%; + -webkit-transform: translateY(-50%); + transform: translateY(-50%); + transition: color 0.3s ease; +} + +.simpleDialog .header .closeIcon { + color: transparent; + display: block; + font-size: 0; + height: 100%; + line-height: 0; + overflow: hidden; + position: relative; + width: 100%; +} + +.simpleDialog .header .closeIcon:after, +.simpleDialog .header .closeIcon:before { + content: ' '; + display: block; + height: 2px; + transition: background-color 0.3s ease; + width: 19px; + background-color: #91b9cd; +} + +.simpleDialog .header .closeIcon:before { + left: 1px; + position: absolute; + top: 1px; + -webkit-transform: rotate(45deg); + transform: rotate(45deg); + -webkit-transform-origin: 1px 50%; + transform-origin: 1px 50%; +} + +.simpleDialog .header .closeIcon:after { + right: 1px; + position: absolute; + top: 1px; + -webkit-transform: rotate(-45deg); + transform: rotate(-45deg); + -webkit-transform-origin: calc(100% - 1px) 50%; + transform-origin: calc(100% - 1px) 50%; +} + +.simpleDialog .content { + padding: 16px 22px 25px; + position: relative; + color: #fff; +} + +.simpleDialog .content .simpleDialogSelect, +.simpleDialog .content .simpleDialogInput { + background-color: #fff; + color: #000; + border: 0; + border-radius: 2px; + font-size: 14px; + height: 30px; + width: 100%; + line-height: 16px; + padding: 8px 9px 6px; + margin-top: 10px; + display: block; +} + +.simpleDialog .content .simpleDialogInputLabel { + font-size: 14px; + font-weight: 700; + line-height: 16px; + color: #fff; +} + +.simpleDialog .footer { + padding: 15px; + text-align: right; +} + +.simpleDialog .footer .btn { + transition: background-color 0.3s ease; + color: #000; + border: 0; + border-radius: 4px; + font-size: 15px; + font-weight: 400; + height: 37px; + line-height: 37px; + padding: 0 12px; + margin-bottom: 0; + margin-left: 5px; +} + +.simpleDialog .footer .btn-confirm { + color: #fff; + background-color: #337ab7; +} diff --git a/src/components/SimpleDialog/SimpleDialog.js b/src/components/SimpleDialog/SimpleDialog.js new file mode 100644 index 000000000..3cb54fe89 --- /dev/null +++ b/src/components/SimpleDialog/SimpleDialog.js @@ -0,0 +1,56 @@ +import { Component } from 'react' +import React from 'react' +import PropTypes from 'prop-types' + +import './SimpleDialog.css' + +class SimpleDialog extends Component { + static defaultProps = { + componentStyle: {}, + rootClass: '', + } + + render() { + return ( +
+
+
+ + x + +

{this.props.headerTitle}

+
+
{this.props.children}
+
+ + +
+
+
+ ) + } +} + +SimpleDialog.propTypes = { + headerTitle: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + onConfirm: PropTypes.func.isRequired, +} + +export default SimpleDialog diff --git a/src/connectedComponents/ConnectedLabellingOverlay.js b/src/connectedComponents/ConnectedLabellingOverlay.js new file mode 100644 index 000000000..dd2a728f1 --- /dev/null +++ b/src/connectedComponents/ConnectedLabellingOverlay.js @@ -0,0 +1,24 @@ +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 diff --git a/src/connectedComponents/ConnectedMeasurementTable.js b/src/connectedComponents/ConnectedMeasurementTable.js index 4c2df680e..c474f601a 100644 --- a/src/connectedComponents/ConnectedMeasurementTable.js +++ b/src/connectedComponents/ConnectedMeasurementTable.js @@ -1,35 +1,41 @@ -import { connect } from 'react-redux'; -import { MeasurementTable } from 'react-viewerbase'; -import OHIF from 'ohif-core'; -import moment from 'moment'; +import { connect } from 'react-redux' +import { MeasurementTable } from 'react-viewerbase' +import OHIF from 'ohif-core' +import moment from 'moment' +import cornerstone from 'cornerstone-core' +import jumpToRowItem from '../lib/jumpToRowItem.js' +import getMeasurementLocationCallback from '../lib/getMeasurementLocationCallback' + +const { setViewportSpecificData } = OHIF.redux.actions; +const { MeasurementApi } = OHIF.measurements function groupBy(list, props) { return list.reduce((a, b) => { - (a[b[props]] = a[b[props]] || []).push(b); - return a; - }, {}); + (a[b[props]] = a[b[props]] || []).push(b) + return a + }, {}) } function getAllTools() { - const config = OHIF.measurements.MeasurementApi.getConfiguration(); - let tools = []; + const config = OHIF.measurements.MeasurementApi.getConfiguration() + let tools = [] config.measurementTools.forEach( toolGroup => (tools = tools.concat(toolGroup.childTools)) - ); + ) - return tools; + return tools } function getMeasurementText(measurementData) { - const { location, description } = measurementData; - let text = '...'; + const { location, description } = measurementData + let text = '...' if (location) { - text = location; + text = location if (description) { - text += `(${description})`; + text += `(${description})` } } - return text; + return text } function getDataForEachMeasurementNumber( @@ -37,51 +43,62 @@ function getDataForEachMeasurementNumber( timepoints, displayFunction ) { - const data = []; + const data = [] // on each measurement number we should get each measurement data by available timepoint measurementNumberList.forEach(measurement => { timepoints.forEach(timepoint => { const eachData = { - displayText: '...' - }; - if (measurement.timepointId === timepoint.timepointId) { - eachData.displayText = displayFunction(measurement); + displayText: '...', } - data.push(eachData); - }); - }); + if (measurement.timepointId === timepoint.timepointId) { + eachData.displayText = displayFunction(measurement) + } + data.push(eachData) + }) + }) - return data; + return data } function convertMeasurementsToTableData(toolCollections, timepoints) { - const config = OHIF.measurements.MeasurementApi.getConfiguration(); - const toolGroups = config.measurementTools; - const tools = getAllTools(); + const config = OHIF.measurements.MeasurementApi.getConfiguration() + const toolGroups = config.measurementTools + const tools = getAllTools() const tableMeasurements = toolGroups.map(toolGroup => { return { groupName: toolGroup.name, groupId: toolGroup.id, - measurements: [] - }; - }); + measurements: [], + } + }) Object.keys(toolCollections).forEach(toolId => { - const toolMeasurements = toolCollections[toolId]; - const tool = tools.find(tool => tool.id === toolId); - const { displayFunction } = tool.options.measurementTable; + const toolMeasurements = toolCollections[toolId] + const tool = tools.find(tool => tool.id === toolId) + const { displayFunction } = tool.options.measurementTable // Group by measurementNumber so we can display then all in the same line - const groupedMeasurements = groupBy(toolMeasurements, 'measurementNumber'); + const groupedMeasurements = groupBy(toolMeasurements, 'measurementNumber') Object.keys(groupedMeasurements).forEach(groupedMeasurementsIndex => { const measurementNumberList = - groupedMeasurements[groupedMeasurementsIndex]; + groupedMeasurements[groupedMeasurementsIndex] + + const { + measurementNumber, + lesionNamingNumber, + toolType, + } = measurementNumberList[0] + const measurementId = measurementNumberList[0]._id + //check if all measurements with same measurementNumber will have same LABEL const tableMeasurement = { - measurementId: measurementNumberList[0]._id, label: getMeasurementText(measurementNumberList[0]), + measurementId, + measurementNumber, + lesionNamingNumber, + toolType, hasWarnings: false, //TODO warningTitle: '', //TODO isSplitLesion: false, //TODO @@ -90,48 +107,239 @@ function convertMeasurementsToTableData(toolCollections, timepoints) { measurementNumberList, timepoints, displayFunction - ) - }; + ), + } // find the group object for the tool const toolGroupMeasurements = tableMeasurements.find(group => { - return group.groupId === tool.toolGroup; - }); + return group.groupId === tool.toolGroup + }) // inject the new measurement for this measurementNumer - toolGroupMeasurements.measurements.push(tableMeasurement); - }); - }); + toolGroupMeasurements.measurements.push(tableMeasurement) + }) + }) - return tableMeasurements; + return tableMeasurements } function convertTimepointsToTableData(timepoints) { if (!timepoints || !timepoints.length) { - return []; + return [] } return [ { label: 'Study date:', - date: moment(timepoints[0].latestDate).format('DD-MMM-YY') - } - ]; + date: moment(timepoints[0].latestDate).format('DD-MMM-YY'), + }, + ] } const mapStateToProps = state => { - const { timepoints, measurements } = state.timepointManager; + const { timepoints, measurements } = state.timepointManager return { timepoints: convertTimepointsToTableData(timepoints), measurementCollection: convertMeasurementsToTableData( measurements, timepoints - ) - }; -}; + ), + timepointManager: state.timepointManager, + viewports: state.viewports, + } +} + +const mapDispatchToProps = dispatch => { + return { + dispatchRelabel: (event, measurementData, viewportsState) => { + const activeViewportIndex = + (viewportsState && viewportsState.activeViewportIndex) || 0 + + const enabledElements = cornerstone.getEnabledElements() + if (!enabledElements || enabledElements.length <= activeViewportIndex) { + OHIF.log.error('Failed to find the enabled element') + return + } + + const { element } = enabledElements[activeViewportIndex] + + const eventData = { + event: { + clientX: event.clientX, + clientY: event.clientY, + }, + element, + } + + const { toolType, measurementId } = measurementData + const tool = MeasurementApi.Instance.tools[toolType].find(measurement => { + return measurement._id === measurementId + }) + + const options = { + skipAddLabelButton: true, + editLocation: true, + } + + // Clone the tool not to set empty location initially + const toolForLocation = Object.assign({}, tool, { location: null }) + getMeasurementLocationCallback(eventData, toolForLocation, options) + }, + dispatchEditDescription: (event, measurementData, viewportsState) => { + const activeViewportIndex = + (viewportsState && viewportsState.activeViewportIndex) || 0 + + const enabledElements = cornerstone.getEnabledElements() + if (!enabledElements || enabledElements.length <= activeViewportIndex) { + OHIF.log.error('Failed to find the enabled element') + return + } + + const { element } = enabledElements[activeViewportIndex] + + const eventData = { + event: { + clientX: event.clientX, + clientY: event.clientY, + }, + element, + } + + const { toolType, measurementId } = measurementData + const tool = MeasurementApi.Instance.tools[toolType].find(measurement => { + return measurement._id === measurementId + }) + + const options = { + editDescriptionOnDialog: true, + } + + getMeasurementLocationCallback(eventData, tool, options) + }, + dispatchJumpToRowItem: ( + measurementData, + viewportsState, + timepointManagerState, + options + ) => { + const actionData = jumpToRowItem( + measurementData, + viewportsState, + timepointManagerState, + dispatch, + options + ) + + actionData.viewportSpecificData.forEach(viewportSpecificData => { + const { viewportIndex, displaySet } = viewportSpecificData; + + dispatch(setViewportSpecificData(viewportIndex, displaySet)); + }) + + const { toolType, measurementNumber } = measurementData + const measurementApi = MeasurementApi.Instance + + Object.keys(measurementApi.tools).forEach(toolType => { + const measurements = measurementApi.tools[toolType] + + measurements.forEach(measurement => { + measurement.active = false + }) + }) + + const measurementsToActive = measurementApi.tools[toolType].filter( + measurement => { + return measurement.measurementNumber === measurementNumber + } + ) + + measurementsToActive.forEach(measurementToActive => { + measurementToActive.active = true + }) + + measurementApi.syncMeasurementsAndToolData() + + cornerstone.getEnabledElements().forEach(enabledElement => { + cornerstone.updateImage(enabledElement.element) + }) + + // Needs to update viewports.layout state to set layout + //const layout = actionData.layout; + //dispatch(setLayout(layout)) + + // Needs to update viewports.activeViewportIndex to the first updated viewport + //const viewportIndex = actionData.viewportIndex; + //dispatch(setViewportActive(viewportIndex)); + + // Needs to update timepointsManager.measurements state to set active measurementId + // TODO: Not yet implemented + //dispatch(setActiveMeasurement(measurementData.measurementId)) + + // (later): Needs to set some property on state.extensions.cornerstone to synchronize viewport scrolling + }, + } +} + +const mergeProps = (propsFromState, propsFromDispatch, ownProps) => { + return { + timepoints: propsFromState.timepoints, + measurementCollection: propsFromState.measurementCollection, + selectedMeasurementNumber: ownProps.selectedMeasurementNumber, + ...propsFromDispatch, + onItemClick: (event, measurementData) => { + // TODO: Add timepointId to .data for measurementData? + // TODO: Tooltype should be on the level below? This should + // provide the entire row item? + + const viewportsState = propsFromState.viewports + const timepointManagerState = propsFromState.timepointManager + + // TODO: invertViewportTimepointsOrder should be stored in / read from user preferences + // TODO: childToolKey should come from the measurement table when it supports child tools + const options = { + invertViewportTimepointsOrder: false, + childToolKey: null, + } + + propsFromDispatch.dispatchJumpToRowItem( + measurementData, + viewportsState, + timepointManagerState, + options + ) + }, + onRelabelClick: (event, measurementData) => { + const viewportsState = propsFromState.viewports + propsFromDispatch.dispatchRelabel(event, measurementData, viewportsState) + }, + onEditDescriptionClick: (event, measurementData) => { + const viewportsState = propsFromState.viewports + propsFromDispatch.dispatchEditDescription( + event, + measurementData, + viewportsState + ) + }, + onDeleteClick: (event, measurementData) => { + const { MeasurementHandlers } = OHIF.measurements; + + MeasurementHandlers.onRemoved({ + detail: { + toolType: measurementData.toolType, + measurementData: { + _id: measurementData.measurementId, + lesionNamingNumber: measurementData.lesionNamingNumber, + measurementNumber: measurementData.measurementNumber + } + } + }); + } + } +} const ConnectedMeasurementTable = connect( mapStateToProps, - null -)(MeasurementTable); + mapDispatchToProps, + mergeProps +)(MeasurementTable) -export default ConnectedMeasurementTable; +export default ConnectedMeasurementTable diff --git a/src/connectedComponents/ConnectedToolContextMenu.js b/src/connectedComponents/ConnectedToolContextMenu.js new file mode 100644 index 000000000..429ea347d --- /dev/null +++ b/src/connectedComponents/ConnectedToolContextMenu.js @@ -0,0 +1,34 @@ +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] + let availableTools + + if ( + state.extensions && + state.extensions.cornerstone && + state.extensions.cornerstone.availableTools + ) { + availableTools = state.extensions.cornerstone.availableTools + } + + return { + ...toolContextMenuData, + availableTools, + } +} + +const ConnectedToolContextMenu = connect( + mapStateToProps, + null +)(ToolContextMenu) + +export default ConnectedToolContextMenu diff --git a/src/connectedComponents/ConnectedViewerMain.js b/src/connectedComponents/ConnectedViewerMain.js new file mode 100644 index 000000000..211a99146 --- /dev/null +++ b/src/connectedComponents/ConnectedViewerMain.js @@ -0,0 +1,33 @@ +import { connect } from 'react-redux'; +import ViewerMain from './ViewerMain'; +import OHIF from 'ohif-core'; + +const { setViewportSpecificData, clearViewportSpecificData } = OHIF.redux.actions; + +const mapStateToProps = state => { + const { activeViewportIndex, layout, viewportSpecificData } = state.viewports; + + return { + activeViewportIndex, + layout, + viewportSpecificData + } +}; + +const mapDispatchToProps = dispatch => { + return { + setViewportSpecificData: (viewportIndex, data) => { + dispatch(setViewportSpecificData(viewportIndex, data)); + }, + clearViewportSpecificData: () => { + dispatch(clearViewportSpecificData()); + } + }; +}; + +const ConnectedViewerMain = connect( + mapStateToProps, + mapDispatchToProps +)(ViewerMain); + +export default ConnectedViewerMain; diff --git a/src/connectedComponents/FlexboxLayout.js b/src/connectedComponents/FlexboxLayout.js index ead9bc3e1..6e826b8fe 100644 --- a/src/connectedComponents/FlexboxLayout.js +++ b/src/connectedComponents/FlexboxLayout.js @@ -1,38 +1,38 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import ConnectedStudyBrowser from './ConnectedStudyBrowser.js'; -import ViewerMain from './ViewerMain.js'; -import ConnectedMeasurementTable from './ConnectedMeasurementTable'; -import './FlexboxLayout.css'; +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import ConnectedStudyBrowser from './ConnectedStudyBrowser.js' +import ConnectedViewerMain from './ConnectedViewerMain.js' +import ConnectedMeasurementTable from './ConnectedMeasurementTable' +import './FlexboxLayout.css' class FlexboxLayout extends Component { static propTypes = { studies: PropTypes.array.isRequired, leftSidebarOpen: PropTypes.bool.isRequired, - rightSidebarOpen: PropTypes.bool.isRequired - }; + rightSidebarOpen: PropTypes.bool.isRequired, + } state = { - studiesForBrowser: [] - }; + studiesForBrowser: [], + } componentDidMount() { - const studiesForBrowser = this.getStudiesForBrowser(); + const studiesForBrowser = this.getStudiesForBrowser() this.setState({ - studiesForBrowser - }); + studiesForBrowser, + }) } getStudiesForBrowser = () => { - const { studies } = this.props; + const { studies } = this.props // TODO[react]: // - Add sorting of display sets // - Add useMiddleSeriesInstanceAsThumbnail // - Add showStackLoadingProgressBar option return studies.map(study => { - const { studyInstanceUid } = study; + const { studyInstanceUid } = study const thumbnails = study.displaySets.map(displaySet => { const { @@ -40,13 +40,13 @@ class FlexboxLayout extends Component { seriesDescription, seriesNumber, instanceNumber, - numImageFrames - } = displaySet; + numImageFrames, + } = displaySet - let imageId; + let imageId if (displaySet.images && displaySet.images.length) { - imageId = displaySet.images[0].getImageId(); + imageId = displaySet.images[0].getImageId() } return { @@ -55,25 +55,25 @@ class FlexboxLayout extends Component { seriesDescription, seriesNumber, instanceNumber, - numImageFrames - }; - }); + numImageFrames, + } + }) return { studyInstanceUid, - thumbnails - }; - }); - }; + thumbnails, + } + }) + } render() { - let mainContentClassName = 'main-content'; + let mainContentClassName = 'main-content' if (this.props.leftSidebarOpen) { - mainContentClassName += ' sidebar-left-open'; + mainContentClassName += ' sidebar-left-open' } if (this.props.rightSidebarOpen) { - mainContentClassName += ' sidebar-right-open'; + mainContentClassName += ' sidebar-right-open' } // TODO[react]: Make ConnectedMeasurementTable extension with state.timepointManager @@ -89,7 +89,7 @@ class FlexboxLayout extends Component {
- +
- ); + ) } } -export default FlexboxLayout; +export default FlexboxLayout diff --git a/src/connectedComponents/LabellingOverlay.js b/src/connectedComponents/LabellingOverlay.js new file mode 100644 index 000000000..564e35f73 --- /dev/null +++ b/src/connectedComponents/LabellingOverlay.js @@ -0,0 +1,23 @@ +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 + } +} + +export default LabellingOverlay diff --git a/src/connectedComponents/ToolContextMenu.css b/src/connectedComponents/ToolContextMenu.css new file mode 100644 index 000000000..42bd2ca6f --- /dev/null +++ b/src/connectedComponents/ToolContextMenu.css @@ -0,0 +1,34 @@ +.ToolContextMenu { + position: absolute; + background-color: white; + border: 1px solid white; + border-radius: 5px; + z-index: 1000; + display: block; + width: 170px; +} + +.ToolContextMenu > ul { + list-style-type: none; + padding-left: 0; + margin: 0; +} + +.ToolContextMenu > ul > li > button { + padding: 10px; + font-size: 14px; + border: none; + color: #516873; + border-radius: 3px; + outline: none; + cursor: pointer; + background: none; +} + +.ToolContextMenu > ul > li > button:hover { + color: #16202b; +} + +.ToolContextMenu > ul > li > button:active { + color: #79f9fe; +} diff --git a/src/connectedComponents/ToolContextMenu.js b/src/connectedComponents/ToolContextMenu.js new file mode 100644 index 000000000..b37902d39 --- /dev/null +++ b/src/connectedComponents/ToolContextMenu.js @@ -0,0 +1,244 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import cornerstone from 'cornerstone-core' +import cornerstoneTools from 'cornerstone-tools' +import getMeasurementLocationCallback from '../lib/getMeasurementLocationCallback' + +import './ToolContextMenu.css' + +const toolTypes = ['Angle', 'Length'] + +let defaultDropdownItems = [ + { + actionType: 'Delete', + action: ({ nearbyToolData, eventData }) => { + const element = eventData.element + + cornerstoneTools.removeToolState( + element, + nearbyToolData.toolType, + nearbyToolData.tool + ) + cornerstone.updateImage(element) + }, + }, + { + actionType: 'setLabel', + action: ({ nearbyToolData, eventData }) => { + const { tool } = nearbyToolData + + const options = { + skipAddLabelButton: true, + editLocation: true, + } + + getMeasurementLocationCallback(eventData, tool, options) + }, + }, + { + actionType: 'setDescription', + action: ({ nearbyToolData, eventData }) => { + const { tool } = nearbyToolData + + const options = { + editDescriptionOnDialog: true, + } + + getMeasurementLocationCallback(eventData, tool, options) + }, + }, +] + +function getNearbyToolData(element, coords, toolTypes) { + const nearbyTool = {} + let pointNearTool = false + + toolTypes.forEach(toolType => { + const toolData = cornerstoneTools.getToolState(element, toolType) + if (!toolData) { + return + } + + toolData.data.forEach(function(data, index) { + // TODO: Fix this, it's ugly + let toolInterface = cornerstoneTools.getToolForElement(element, toolType) + if (!toolInterface) { + toolInterface = cornerstoneTools.getToolForElement( + element, + `${toolType}Tool` + ) + } + + if (!toolInterface) { + throw new Error('Tool not found.') + } + + 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, availableTools) { + const nearbyToolData = getNearbyToolData( + eventData.element, + eventData.currentPoints.canvas, + toolTypes, + availableTools + ) + + // 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, + availableTools: PropTypes.array, + 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, availableTools } = this.props + const dropdownItems = getDropdownItems( + eventData, + isTouchEvent, + availableTools + ) + + // 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 ( +
  • + +
  • + ) + }) + + const position = { + top: `${eventData.currentPoints.canvas.y}px`, + left: `${eventData.currentPoints.canvas.x}px`, + } + + return ( +
    +
      {dropdownComponents}
    +
    + ) + } + + componentDidMount = () => { + if (this.mainElement.current) { + this.updateElementPosition() + } + } + + componentDidUpdate = () => { + if (this.mainElement.current) { + 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 diff --git a/src/connectedComponents/Viewer.js b/src/connectedComponents/Viewer.js index 76ee40075..8176d9fd6 100644 --- a/src/connectedComponents/Viewer.js +++ b/src/connectedComponents/Viewer.js @@ -9,6 +9,7 @@ import WhiteLabellingContext from '../WhiteLabellingContext.js'; import ConnectedHeader from './ConnectedHeader.js'; import ConnectedFlexboxLayout from './ConnectedFlexboxLayout.js'; import ConnectedToolbarRow from './ConnectedToolbarRow.js'; +import ConnectedLabellingOverlay from './ConnectedLabellingOverlay'; import './Viewer.css'; /** * Inits OHIF Hanging Protocol's onReady. @@ -184,6 +185,7 @@ class Viewer extends Component { {/**/} {/**/} + ); diff --git a/src/connectedComponents/ViewerMain.js b/src/connectedComponents/ViewerMain.js index f33f60185..93dd9395b 100644 --- a/src/connectedComponents/ViewerMain.js +++ b/src/connectedComponents/ViewerMain.js @@ -6,23 +6,28 @@ import ConnectedLayoutManager from './ConnectedLayoutManager.js'; import './ViewerMain.css'; class ViewerMain extends Component { - state = { - viewportData: [] - }; - static propTypes = { - studies: PropTypes.array.isRequired + studies: PropTypes.array.isRequired, + setViewportSpecificData: PropTypes.func.isRequired, + clearViewportSpecificData: PropTypes.func.isRequired }; constructor(props) { super(props); OHIF.hotkeysUtil.setup('viewer'); + + this.state = { + displaySets: [] + } } getDisplaySets(studies) { const displaySets = []; studies.forEach(study => { study.displaySets.forEach(dSet => { + if (!dSet.plugin) { + dSet.plugin = 'cornerstone'; + } displaySets.push(dSet); }); }); @@ -52,29 +57,37 @@ class ViewerMain extends Component { const displaySets = this.getDisplaySets(this.props.studies); this.setState({ - viewportData: displaySets + displaySets }); } + getViewportData = () => { + const viewportData = []; + const { layout, viewportSpecificData } = this.props; + + for (let viewportIndex = 0; viewportIndex < layout.viewports.length; viewportIndex++) { + let displaySet = viewportSpecificData[viewportIndex]; + + // If the viewport is empty, get one available in study + if (!displaySet || !displaySet.displaySetInstanceUid) { + const { displaySets } = this.state; + displaySet = displaySets.find(ds => !viewportData.some(v => v.displaySetInstanceUid === ds.displaySetInstanceUid)); + } + + viewportData.push(displaySet); + } + + return viewportData; + }; + setViewportData = ({ viewportIndex, item }) => { - // TODO: Replace this with mapDispatchToProps call - // if we decide to put viewport info into redux - - // Note: Use Slice because React does a shallow equality check. Mutating the array - // would not trigger a re-render. We have to create a copy. - const updatedViewportData = this.state.viewportData.slice(0); - const displaySet = this.findDisplaySet( this.props.studies, item.studyInstanceUid, item.displaySetInstanceUid ); - updatedViewportData[viewportIndex] = Object.assign({}, displaySet); - - this.setState({ - viewportData: updatedViewportData - }); + this.props.setViewportSpecificData(viewportIndex, displaySet); }; render() { @@ -82,7 +95,7 @@ class ViewerMain extends Component {
    @@ -90,6 +103,12 @@ class ViewerMain extends Component { } componentWillUnmount() { + // Clear the entire viewport specific data + const { viewportSpecificData } = this.props; + Object.keys(viewportSpecificData).forEach((viewportIndex) => { + this.props.clearViewportSpecificData(viewportIndex); + }); + // Remove beforeUnload event handler... //window.removeEventListener('beforeunload', unloadHandlers.beforeUnload); // Destroy the synchronizer used to update reference lines diff --git a/src/lib/getMeasurementLocationCallback.js b/src/lib/getMeasurementLocationCallback.js new file mode 100644 index 000000000..3a03bc9b3 --- /dev/null +++ b/src/lib/getMeasurementLocationCallback.js @@ -0,0 +1,21 @@ +import cornerstoneTools from 'cornerstone-tools' +import updateTableWithNewMeasurementData from './updateTableWithNewMeasurementData' + +export default function getMeasurementLocationCallback( + eventData, + tool, + options +) { + const { toolType } = tool + const { element } = eventData + const doneCallback = updateTableWithNewMeasurementData + + const ToolInstance = cornerstoneTools.getToolForElement(element, toolType) + + ToolInstance.configuration.getMeasurementLocationCallback( + tool, + eventData, + doneCallback, + options + ) +} diff --git a/src/lib/jumpToRowItem.js b/src/lib/jumpToRowItem.js new file mode 100644 index 000000000..9b668c3c7 --- /dev/null +++ b/src/lib/jumpToRowItem.js @@ -0,0 +1,110 @@ +import { OHIF } from 'ohif-core' + +// TODO: Move this function to OHIF itself so we can use it on the OHIF measurment table (when it is finished) + +/** + * Activates a set of measurements + * + * @param measurementData + * @param viewportsState + * @param timepointManagerState + * @param options + */ +export default function jumpToRowItem( + measurementData, + viewportsState, + timepointManagerState, + options = { invertViewportTimepointsOrder: false, childToolKey: null } +) { + const numViewports = viewportsState.layout.viewports.length + const numTimepoints = timepointManagerState.timepoints.length + const { measurements, timepoints } = timepointManagerState + const numViewportsToUpdate = Math.min(numTimepoints, numViewports) + const { toolType, measurementNumber } = measurementData + + if (options.invertViewportTimepointsOrder) { + timepoints.reverse() + } + + const measurementsForToolGroup = measurements[toolType] + + // Retrieve the measurements data + const measurementsToJumpTo = [] + for (let i = 0; i < numViewportsToUpdate; i++) { + const { timepointId } = timepoints[i] + + const dataAtThisTimepoint = measurementsForToolGroup.find(entry => { + return ( + entry.timepointId === timepointId && + entry.measurementNumber === measurementNumber + ) + }) + + if (!dataAtThisTimepoint) { + measurementsToJumpTo.push(null) + continue + } + + let measurement = dataAtThisTimepoint + + const { tool } = OHIF.measurements.MeasurementApi.getToolConfiguration( + toolType + ) + if (options.childToolKey) { + measurement = dataAtThisTimepoint[options.childToolKey] + } else if (Array.isArray(tool.childTools)) { + const key = tool.childTools.find(key => !!dataAtThisTimepoint[key]) + measurement = dataAtThisTimepoint[key] + } + + measurementsToJumpTo.push(measurement) + } + + // TODO: Add a single viewports state action which allows + // - viewportData to be set + // - layout to be set + // - activeViewport to be set + + // Needs to update viewports.viewportData state to set image set data + + const displaySetContainsSopInstance = (displaySet, sopInstanceUid) => + displaySet.images.find( + image => image.getSOPInstanceUID() === sopInstanceUid + ) + + const viewportSpecificData = [] + measurementsToJumpTo.forEach((data, viewportIndex) => { + // Skip if there is no measurement to jump + if (!data) { + return + } + + const study = OHIF.utils.studyMetadataManager.get(data.studyInstanceUid) + if (!study) { + throw new Error('Study not found.') + } + + const displaySet = study.findDisplaySet(displaySet => { + return displaySetContainsSopInstance(displaySet, data.sopInstanceUid) + }) + + if (!displaySet) { + throw new Error('Display set not found.') + } + + displaySet.sopInstanceUid = data.sopInstanceUid + if (data.frameIndex) { + displaySet.frameIndex = data.frameIndex + } + + viewportSpecificData.push({ + viewportIndex, + displaySet, + }) + }) + + return { + viewportSpecificData, + layout: [], // TODO: if we need to change layout, we should return this here + } +} diff --git a/src/lib/updateTableWithNewMeasurementData.js b/src/lib/updateTableWithNewMeasurementData.js new file mode 100644 index 000000000..534ccdc8d --- /dev/null +++ b/src/lib/updateTableWithNewMeasurementData.js @@ -0,0 +1,29 @@ +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) + }) +} diff --git a/src/lib/utils/bounding.js b/src/lib/utils/bounding.js new file mode 100644 index 000000000..6784e5155 --- /dev/null +++ b/src/lib/utils/bounding.js @@ -0,0 +1,46 @@ +export default function bounding(elementRef, currentPosition = {}) { + if (!elementRef.current) { + return + } + + const currentElement = elementRef.current + const { + offsetParent, + offsetTop, + offsetHeight, + offsetLeft, + offsetWidth, + } = currentElement + let top = currentPosition.top || offsetTop + let left = currentPosition.left || offsetLeft + + if (!offsetParent) { + return + } + + let maxHeight = `${offsetParent.offsetHeight}px` + + if (offsetHeight + top > offsetParent.offsetHeight) { + top = top - (offsetHeight + top - offsetParent.offsetHeight) + if (top < 0) { + top = 0 + } + } + + if (left + offsetWidth > offsetParent.offsetWidth) { + left = offsetParent.offsetWidth - offsetWidth + if (left < 0) { + left = 0 + } + } + + if (maxHeight && currentElement.style.maxHeight !== maxHeight) { + currentElement.style.maxHeight = maxHeight + } + if (currentElement.style.top !== `${top}px`) { + currentElement.style.top = `${top}px` + } + if (currentElement.style.left !== `${left}px`) { + currentElement.style.left = `${left}px` + } +} diff --git a/src/redux/ui.js b/src/redux/ui.js index 74205b3ec..a58a596f5 100644 --- a/src/redux/ui.js +++ b/src/redux/ui.js @@ -1,22 +1,42 @@ const defaultState = { leftSidebarOpen: true, rightSidebarOpen: false, - userPreferencesModalOpen: false -}; + userPreferencesModalOpen: false, + labelling: {}, + contextMenu: {}, +} const ui = (state = defaultState, action) => { switch (action.type) { case 'SET_LEFT_SIDEBAR_OPEN': - return Object.assign({}, state, { leftSidebarOpen: action.state }); + return Object.assign({}, state, { leftSidebarOpen: action.state }) case 'SET_RIGHT_SIDEBAR_OPEN': - return Object.assign({}, state, { rightSidebarOpen: action.state }); + return Object.assign({}, state, { rightSidebarOpen: action.state }) case 'SET_USER_PREFERENCES_MODAL_OPEN': return Object.assign({}, state, { - userPreferencesModalOpen: action.state - }); - default: - return state; - } -}; + userPreferencesModalOpen: action.state, + }) + case 'SET_LABELLING_FLOW_DATA': + const labelling = Object.assign({}, action.labellingFlowData) -export default ui; + 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 diff --git a/src/setupButtons.js b/src/setupButtons.js new file mode 100644 index 000000000..418f8db37 --- /dev/null +++ b/src/setupButtons.js @@ -0,0 +1,89 @@ +import OHIF from 'ohif-core' + +const Icons = 'icons.svg' + +export default function setupButtons(store) { + const defaultButtons = [ + { + command: 'StackScroll', + type: 'tool', + text: 'Stack Scroll', + svgUrl: `${Icons}#icon-tools-stack-scroll`, + active: false, + }, + { + command: 'Zoom', + type: 'tool', + text: 'Zoom', + svgUrl: `${Icons}#icon-tools-zoom`, + active: false, + }, + { + command: 'Wwwc', + type: 'tool', + text: 'Levels', + svgUrl: `${Icons}#icon-tools-levels`, + active: true, + }, + { + command: 'Pan', + type: 'tool', + text: 'Pan', + svgUrl: `${Icons}#icon-tools-pan`, + active: false, + }, + { + command: 'Length', + type: 'tool', + text: 'Length', + svgUrl: `${Icons}#icon-tools-measure-temp`, + active: false, + }, + /*{ + command: 'Annotate', + type: 'tool', + text: 'Annotate', + svgUrl: `${Icons}#icon-tools-measure-non-target`, + active: false + },*/ + { + command: 'Angle', + type: 'tool', + text: 'Angle', + iconClasses: 'fa fa-angle-left', + active: false, + }, + { + command: 'Bidirectional', + type: 'tool', + text: 'Bidirectional', + svgUrl: `${Icons}#icon-tools-measure-target`, + active: false, + }, + { + command: 'Brush', + type: 'tool', + text: 'Brush', + iconClasses: 'fa fa-circle', + active: false, + }, + { + command: 'FreehandMouse', + type: 'tool', + text: 'Freehand', + iconClasses: 'fa fa-star', + active: false, + }, + { + command: 'reset', + type: 'command', + text: 'Reset', + svgUrl: `${Icons}#icon-tools-reset`, + active: false, + }, + ] + + const buttonsAction = OHIF.redux.actions.setAvailableButtons(defaultButtons) + + store.dispatch(buttonsAction) +} diff --git a/src/setupTools.js b/src/setupTools.js new file mode 100644 index 000000000..8133780dc --- /dev/null +++ b/src/setupTools.js @@ -0,0 +1,190 @@ +import OHIF from 'ohif-core' +import updateTableWithNewMeasurementData from './lib/updateTableWithNewMeasurementData' + +function getToolLabellingFlowCallback(store) { + const setLabellingFlowDataAction = labellingFlowData => ({ + type: 'SET_LABELLING_FLOW_DATA', + labellingFlowData, + }) + + 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 + } + if (description) { + 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) + } +} + +const resetLabellingAndContextMenuAction = state => ({ + type: 'RESET_LABELLING_AND_CONTEXT_MENU', + state, +}) + +const setToolContextMenuDataAction = (viewportIndex, toolContextMenuData) => ({ + type: 'SET_TOOL_CONTEXT_MENU_DATA', + viewportIndex, + toolContextMenuData, +}) + +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 = parseInt(eventData.element.dataset.viewportIndex, 10) + + const toolContextMenuData = { + eventData, + isTouchEvent: false, + onClose: getOnCloseCallback(viewportIndex), + } + + setToolContextMenuData(viewportIndex, 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()) + } +} + +export default function setupTools(store) { + const toolLabellingFlowCallback = getToolLabellingFlowCallback(store) + const availableTools = [ + { name: 'Pan', mouseButtonMasks: [1, 4] }, + { name: 'Zoom', mouseButtonMasks: [1, 2] }, + { name: 'Wwwc', mouseButtonMasks: [1] }, + { + name: 'Length', + configuration: { + configuration: { + getMeasurementLocationCallback: toolLabellingFlowCallback, + }, + }, + mouseButtonMasks: [1], + }, + { + name: 'Angle', + configuration: { + configuration: { + getMeasurementLocationCallback: toolLabellingFlowCallback, + }, + }, + mouseButtonMasks: [1], + }, + { name: 'StackScroll', mouseButtonMasks: [1] }, + { name: 'Brush', mouseButtonMasks: [1] }, + { name: 'FreehandMouse', mouseButtonMasks: [1] }, + { name: 'PanMultiTouch' }, + { name: 'ZoomTouchPinch' }, + { name: 'StackScrollMouseWheel' }, + { name: 'StackScrollMultiTouch' }, + ] + + const onRightClick = getOnRightClickCallback(store) + const onTouchPress = getOnTouchPressCallback(store) + const onNewImage = getResetLabellingAndContextMenu(store) + const onMouseClick = getResetLabellingAndContextMenu(store) + const onTouchStart = getResetLabellingAndContextMenu(store) + const toolAction = OHIF.redux.actions.setExtensionData('cornerstone', { + availableTools, + onNewImage, + onRightClick, + onTouchPress, + onTouchStart, + onMouseClick, + }) + + store.dispatch(toolAction) +} diff --git a/src/utils/getDefaultToolbarButtons.js b/src/utils/getDefaultToolbarButtons.js index 657e057b7..84ff9e958 100644 --- a/src/utils/getDefaultToolbarButtons.js +++ b/src/utils/getDefaultToolbarButtons.js @@ -60,13 +60,6 @@ export default function(baseDirectory = '/') { iconClasses: 'fa fa-angle-left', active: false, }, - { - command: 'Bidirectional', - type: 'tool', - text: 'Bidirectional', - svgUrl: `${relativePathToIcons}#icon-tools-measure-target`, - active: false, - }, { command: 'Brush', type: 'tool', diff --git a/yarn.lock b/yarn.lock index ca5101c36..5df38551e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9603,10 +9603,10 @@ octokit-pagination-methods@^1.1.0: resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4" integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ== -ohif-core@0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/ohif-core/-/ohif-core-0.4.1.tgz#3a2b0b92b22f8da80b04fe487dc1faf6c0c2ed6c" - integrity sha512-QJRnx3NDf45a655JZQMCNj9tLtyh5YaH7X/tGPsA0Y2zPWEKxWKAQQDUE+TCZNsec135mB+xxYw++a/GkqLX0A== +ohif-core@0.4.2: + version "0.4.2" + resolved "https://registry.yarnpkg.com/ohif-core/-/ohif-core-0.4.2.tgz#1ed2db0cddbd9ac15d5c97b9c789793d791be258" + integrity sha512-Wddeta9hKZDe1yyPp2QgtxhL0nM3vSudq52gRXcIVmtl0xCHzRrWqPqCTX10R3BU8OUXsHEF66RQ2gSVJmjH7g== dependencies: "@babel/runtime" "^7.2.0" ajv "^6.10.0"