diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 12dd14100..226be4bc4 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -33,9 +33,9 @@ "@ohif/ui": "^0.50.0", "cornerstone-core": "^2.3.0", "cornerstone-math": "^0.1.8", - "cornerstone-tools": "4.16.0", - "dcmjs": "0.14.0", + "cornerstone-tools": "4.16.1", "cornerstone-wado-image-loader": "^3.1.2", + "dcmjs": "0.14.0", "dicom-parser": "^1.8.3", "hammerjs": "^2.0.8", "prop-types": "^15.6.2", diff --git a/extensions/default/src/ActionButtons.jsx b/extensions/default/src/ActionButtons.jsx deleted file mode 100644 index 0e3e8a3b6..000000000 --- a/extensions/default/src/ActionButtons.jsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { Button, ButtonGroup, Icon, IconButton } from '@ohif/ui'; - -function ActionButtons() { - return ( - - alert('Export')}> - - - - - - - - ); -} - -export default ActionButtons; diff --git a/extensions/default/src/PanelMeasurementTable.js b/extensions/default/src/PanelMeasurementTable.js index 6da4696fd..0967ac378 100644 --- a/extensions/default/src/PanelMeasurementTable.js +++ b/extensions/default/src/PanelMeasurementTable.js @@ -1,55 +1,170 @@ -import React from 'react'; -import { StudySummary, MeasurementTable } from '@ohif/ui'; -import ActionButtons from './ActionButtons.jsx'; +import React, { useEffect, useState } from 'react'; +import PropTypes from 'prop-types'; +import { MeasurementTable } from '@ohif/ui'; +import { DicomMetadataStore } from '@ohif/core'; +import debounce from './debounce.js'; export default function PanelMeasurementTable({ servicesManager, - commandsManager, + // commandsManager, }) { const { MeasurementService } = servicesManager.services; + const [displayMeasurements, setDisplayMeasurements] = useState([]); - console.log('MeasurementTable rendering!!!!!!!!!!!!!'); + useEffect(() => { + const debouncedSetDisplayMeasurements = debounce( + setDisplayMeasurements, + 100 + ); + // ~~ Initial + setDisplayMeasurements(_getMappedMeasurements(MeasurementService)); - const descriptionData = { - date: '07-Sep-2010', - modality: 'CT', - description: 'CHEST/ABD/PELVIS W CONTRAST', - }; + // ~~ Subscription + const added = MeasurementService.EVENTS.MEASUREMENT_ADDED; + const updated = MeasurementService.EVENTS.MEASUREMENT_UPDATED; + const removed = MeasurementService.EVENTS.MEASUREMENT_REMOVED; + const subscriptions = []; - const activeMeasurementItem = 0; + [added, updated, removed].forEach(evt => { + subscriptions.push( + MeasurementService.subscribe(evt, () => { + debouncedSetDisplayMeasurements( + _getMappedMeasurements(MeasurementService) + ); + }).unsubscribe + ); + }); - const measurementTableData = { - title: 'Measurements', - amount: 10, - data: new Array(10).fill({}).map((el, i) => ({ - id: i + 1, - label: 'Label short description', - displayText: '24.0 x 24.0 mm (S:4, I:22)', - isActive: activeMeasurementItem === i + 1, - })), - onClick: id => setActiveMeasurementItem(s => (s === id ? null : id)), - onEdit: id => alert(`Edit: ${id}`), - }; + return () => { + subscriptions.forEach(unsub => { + unsub(); + }); + }; + }, [MeasurementService]); + + // const activeMeasurementItem = 0; return ( <>
- {}} onEdit={id => alert(`Edit: ${id}`)} />
-
- -
); } + +PanelMeasurementTable.propTypes = { + servicesManager: PropTypes.shape({ + services: PropTypes.shape({ + MeasurementService: PropTypes.shape({ + getMeasurements: PropTypes.func.isRequired, + subscribe: PropTypes.func.isRequired, + EVENTS: PropTypes.object.isRequired, + VALUE_TYPES: PropTypes.object.isRequired, + }).isRequired, + }).isRequired, + }).isRequired, +}; + +function _getMappedMeasurements(MeasurementService) { + const measurements = MeasurementService.getMeasurements(); + const mappedMeasurements = measurements.map((m, index) => + _mapMeasurementToDisplay(m, index, MeasurementService.VALUE_TYPES) + ); + + return mappedMeasurements; +} + +function _mapMeasurementToDisplay(measurement, index, types) { + const { + id, + label, + description, + // Reference IDs + referenceStudyUID, + referenceSeriesUID, + SOPInstanceUID, + } = measurement; + const instance = DicomMetadataStore.getInstance( + referenceStudyUID, + referenceSeriesUID, + SOPInstanceUID + ); + const { PixelSpacing, SeriesNumber, InstanceNumber } = instance; + + return { + id: index + 1, + label: '(empty)', // 'Label short description', + displayText: + _getDisplayText( + measurement, + PixelSpacing, + SeriesNumber, + InstanceNumber, + types + ) || [], + // TODO: handle one layer down + isActive: false, // activeMeasurementItem === i + 1, + }; +} + +function _getDisplayText( + measurement, + pixelSpacing, + seriesNumber, + instanceNumber, + types +) { + const { type, points } = measurement; + const hasPixelSpacing = + pixelSpacing !== undefined && + Array.isArray(pixelSpacing) && + pixelSpacing.length === 2; + const [rowPixelSpacing, colPixelSpacing] = hasPixelSpacing + ? pixelSpacing + : [1, 1]; + const unit = hasPixelSpacing ? 'mm' : 'px'; + + switch (type) { + case types.POLYLINE: { + const { length } = measurement; + const roundedLength = _round(length, 1); + + return [ + `${roundedLength} ${unit} (S:${seriesNumber}, I:${instanceNumber})`, + ]; + } + case types.BIDIRECTIONAL: { + const { shortestDiameter, longestDiameter } = measurement; + const roundedShortestDiameter = _round(shortestDiameter, 1); + const roundedLongestDiameter = _round(longestDiameter, 1); + + return [ + `l: ${roundedLongestDiameter} ${unit} (S:${seriesNumber}, I:${instanceNumber})`, + `s: ${roundedShortestDiameter} ${unit}`, + ]; + } + case types.ELLIPSE: { + const { area } = measurement; + const roundedArea = _round(area, 1); + + return [ + `${roundedArea} ${unit}2 (S:${seriesNumber}, I:${instanceNumber})`, + ]; + } + case types.POINT: { + const { text } = measurement; + return [`${text} (S:${seriesNumber}, I:${instanceNumber})`]; + } + } +} + +function _round(value, decimals) { + return Number(Math.round(value + 'e' + decimals) + 'e-' + decimals); +} diff --git a/extensions/default/src/Panels/PanelStudyBrowser.jsx b/extensions/default/src/Panels/PanelStudyBrowser.jsx index 2d66fc634..43df658a2 100644 --- a/extensions/default/src/Panels/PanelStudyBrowser.jsx +++ b/extensions/default/src/Panels/PanelStudyBrowser.jsx @@ -21,15 +21,13 @@ function PanelStudyBrowser({ // Tabs --> Studies --> DisplaySets --> Thumbnails const [{ StudyInstanceUIDs }, dispatch] = useImageViewer(); const [activeTabName, setActiveTabName] = useState('primary'); - const [expandedStudyInstanceUIDs, setExpandedStudyInstanceUIDs] = useState( - [] - ); + const [expandedStudyInstanceUIDs, setExpandedStudyInstanceUIDs] = useState([ + ...StudyInstanceUIDs, + ]); const [studyDisplayList, setStudyDisplayList] = useState([]); const [displaySets, setDisplaySets] = useState([]); const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({}); - console.log(DisplaySetService); - // ~~ studyDisplayList useEffect(() => { // Fetch all studies for the patient in each primary study diff --git a/extensions/default/src/debounce.js b/extensions/default/src/debounce.js new file mode 100644 index 000000000..83a16b099 --- /dev/null +++ b/extensions/default/src/debounce.js @@ -0,0 +1,21 @@ +// Returns a function, that, as long as it continues to be invoked, will not +// be triggered. The function will be called after it stops being called for +// N milliseconds. If `immediate` is passed, trigger the function on the +// leading edge, instead of the trailing. +function debounce(func, wait, immediate) { + var timeout; + return function() { + var context = this, + args = arguments; + var later = function() { + timeout = null; + if (!immediate) func.apply(context, args); + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) func.apply(context, args); + }; +} + +export default debounce; diff --git a/extensions/dicom-rt/package.json b/extensions/dicom-rt/package.json index 9b7fc3c38..323ea25d8 100644 --- a/extensions/dicom-rt/package.json +++ b/extensions/dicom-rt/package.json @@ -30,7 +30,7 @@ "peerDependencies": { "@ohif/core": "^0.50.0", "cornerstone-core": "^2.2.8", - "cornerstone-tools": "4.16.0", + "cornerstone-tools": "4.16.1", "dcmjs": "0.14.0", "prop-types": "^15.6.2", "react": "^16.8.6", diff --git a/extensions/dicom-segmentation/package.json b/extensions/dicom-segmentation/package.json index e9214776f..9306becf7 100644 --- a/extensions/dicom-segmentation/package.json +++ b/extensions/dicom-segmentation/package.json @@ -30,7 +30,7 @@ "peerDependencies": { "@ohif/core": "^0.50.0", "cornerstone-core": "^2.2.8", - "cornerstone-tools": "4.15.1", + "cornerstone-tools": "4.16.1", "dcmjs": "0.14.0", "prop-types": "^15.6.2", "react": "^16.8.6", diff --git a/extensions/dicom-sr/package.json b/extensions/dicom-sr/package.json index 7ea514c52..731b7d39c 100644 --- a/extensions/dicom-sr/package.json +++ b/extensions/dicom-sr/package.json @@ -33,9 +33,9 @@ "@ohif/ui": "^0.50.0", "cornerstone-core": "^2.3.0", "cornerstone-math": "^0.1.8", - "cornerstone-tools": "4.16.0", - "dcmjs": "0.14.0", + "cornerstone-tools": "4.16.1", "cornerstone-wado-image-loader": "^3.1.2", + "dcmjs": "0.14.0", "dicom-parser": "^1.8.3", "hammerjs": "^2.0.8", "prop-types": "^15.6.2", diff --git a/extensions/dicom-sr/src/getSopClassHandlerModule.js b/extensions/dicom-sr/src/getSopClassHandlerModule.js index fc2a39980..ec91a1bc5 100644 --- a/extensions/dicom-sr/src/getSopClassHandlerModule.js +++ b/extensions/dicom-sr/src/getSopClassHandlerModule.js @@ -1,9 +1,7 @@ import id from './id'; import { utils, classes } from '@ohif/core'; -import addMeasurement from './utils/addMeasurement.js'; -import { adapters } from 'dcmjs'; - -const cornerstoneAdapters = adapters.Cornerstone; +import addMeasurement from './utils/addMeasurement'; +import isRehydratable from './utils/isRehydratable'; const { ImageSet } = classes; @@ -95,7 +93,12 @@ function _getDisplaySetsFromSeries( sopClassUids, }; - if (_isRehydratable(displaySet, MeasurementService)) { + const mappings = MeasurementService.getSourceMappings( + 'CornerstoneTools', + '4' + ); + + if (isRehydratable(displaySet, mappings)) { displaySet.isLocked = false; displaySet.isHydrated = false; } else { diff --git a/extensions/dicom-sr/src/index.js b/extensions/dicom-sr/src/index.js index 879478388..498f51517 100644 --- a/extensions/dicom-sr/src/index.js +++ b/extensions/dicom-sr/src/index.js @@ -25,6 +25,7 @@ export default { id, dependencies: [ // TODO -> This isn't used anywhere yet, but we do have a hard dependency, and need to check for these in the future. + // OHIF-229 { id: 'org.ohif.cornerstone', version: '3.0.0', diff --git a/extensions/dicom-sr/src/utils/getToolStateToCornerstoneMeasurementSchema.js b/extensions/dicom-sr/src/utils/getToolStateToCornerstoneMeasurementSchema.js index 2bdd3d16b..d37b8c0be 100644 --- a/extensions/dicom-sr/src/utils/getToolStateToCornerstoneMeasurementSchema.js +++ b/extensions/dicom-sr/src/utils/getToolStateToCornerstoneMeasurementSchema.js @@ -14,6 +14,7 @@ export default function getToolStateToCornerstoneMeasurementSchema( // TODO -> I get why this was attemped, but its not nearly flexible enough. // A single measurement may have an ellipse + a bidirectional measurement, for instances. // You can't define a bidirectional tool as a single type.. + // OHIF-230 const TOOL_TYPE_TO_VALUE_TYPE = { Length: POLYLINE, EllipticalRoi: ELLIPSE, diff --git a/extensions/dicom-sr/src/utils/isRehydratable.js b/extensions/dicom-sr/src/utils/isRehydratable.js new file mode 100644 index 000000000..750d184e0 --- /dev/null +++ b/extensions/dicom-sr/src/utils/isRehydratable.js @@ -0,0 +1,48 @@ +import { adapters } from 'dcmjs'; + +const cornerstoneAdapters = adapters.Cornerstone; + +/** + * Checks if the given `displySet`can be rehydrated into the `MeasurementService`. + * + * @param {object} displaySet The SR `displaySet` to check. + * @param {object[]} mappings The CornerstoneTools 4 mappings to the `MeasurementService`. + * @returns {boolean} True if the SR can be rehydrated into the `MeasurementService`. + */ +export default function isRehydratable(displaySet, mappings) { + if (!mappings || !mappings.length) { + return false; + } + + const mappingDefinitions = mappings.map(m => m.definition); + const { measurements } = displaySet; + + const adapterKeys = Object.keys(cornerstoneAdapters).filter( + adapterKey => + typeof cornerstoneAdapters[adapterKey] + .isValidCornerstoneTrackingIdentifier === 'function' + ); + + const adapters = []; + + adapterKeys.forEach(key => { + if (mappingDefinitions.includes(key)) { + // Must have both a dcmjs adapter and a MeasurementService + // Definition in order to be a candidate for import. + adapters.push(cornerstoneAdapters[key]); + } + }); + + for (let i = 0; i < measurements.length; i++) { + const TrackingIdentifier = measurements[i].TrackingIdentifier; + const hydratable = adapters.some(adapter => + adapter.isValidCornerstoneTrackingIdentifier(TrackingIdentifier) + ); + + if (hydratable) { + return true; + } + } + + return false; +} diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index 86bad62c0..1ed3dbb5b 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -28,12 +28,12 @@ }, "peerDependencies": { "@ohif/core": "^0.50.0", + "cornerstone-tools": "4.16.1", "dcmjs": "0.14.0", "prop-types": "^15.6.2", "react": "^16.13.1", "react-dom": "^16.13.1", - "webpack": "^4.0.0", - "cornerstone-tools": "4.15.1" + "webpack": "^4.0.0" }, "dependencies": { "@babel/runtime": "7.7.6", diff --git a/extensions/measurement-tracking/src/_shared/createReportAsync.js b/extensions/measurement-tracking/src/_shared/createReportAsync.js new file mode 100644 index 000000000..326ad249b --- /dev/null +++ b/extensions/measurement-tracking/src/_shared/createReportAsync.js @@ -0,0 +1,47 @@ +import React from 'react'; +import { DICOMSR } from '@ohif/core'; + +async function createReportAsync(servicesManager, dataSource, measurements) { + const { + UINotificationService, + UIDialogService, + DisplaySetService, + } = servicesManager.services; + const loadingDialogId = UIDialogService.create({ + showOverlay: true, + isDraggable: false, + centralize: true, + // TODO: Create a loading indicator component + zeplin design? + content: Loading, + }); + + try { + const naturalizedReport = await DICOMSR.storeMeasurements( + measurements, + dataSource + ); + + DisplaySetService.makeDisplaySets([naturalizedReport], { + madeInClient: true, + }); + UINotificationService.show({ + title: 'STOW SR', + message: 'Measurements saved successfully', + type: 'success', + }); + } catch (error) { + UINotificationService.show({ + title: 'STOW SR', + message: error.message || 'Failed to store measurements', + type: 'error', + }); + } finally { + UIDialogService.dismiss({ id: loadingDialogId }); + } +} + +function Loading() { + return
Loading...
; +} + +export default createReportAsync; diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.jsx b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.jsx index 5081deaad..3ff87f663 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.jsx +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/TrackedMeasurementsContext.jsx @@ -19,25 +19,33 @@ const useTrackedMeasurements = () => useContext(TrackedMeasurementsContext); * @param {*} param0 */ function TrackedMeasurementsContextProvider( - UIViewportDialogService, - { children } + { servicesManager, extensionManager }, // Bound by consumer + { children } // Component props ) { const machineOptions = Object.assign({}, defaultOptions); machineOptions.services = Object.assign({}, machineOptions.services, { - promptBeginTracking: promptBeginTracking.bind( - null, - UIViewportDialogService - ), - promptTrackNewSeries: promptTrackNewSeries.bind( - null, - UIViewportDialogService - ), - promptTrackNewStudy: promptTrackNewStudy.bind( - null, - UIViewportDialogService - ), + promptBeginTracking: promptBeginTracking.bind(null, { + servicesManager, + extensionManager, + }), + promptTrackNewSeries: promptTrackNewSeries.bind(null, { + servicesManager, + extensionManager, + }), + promptTrackNewStudy: promptTrackNewStudy.bind(null, { + servicesManager, + extensionManager, + }), }); + // TODO: IMPROVE + // - Add measurement_updated to cornerstone; debounced? (ext side, or consumption?) + // - Friendlier transition/api in front of measurementTracking machine? + // - Blocked: viewport overlay shouldn't clip when resized + // TODO: PRIORITY + // - Fix "ellipses" series description dynamic truncate length + // - Fix viewport border resize + // - created/destroyed hooks for extensions (cornerstone measurement subscriptions in it's `init`) const measurementTrackingMachine = Machine( machineConfiguration, @@ -61,6 +69,8 @@ function TrackedMeasurementsContextProvider( TrackedMeasurementsContextProvider.propTypes = { children: PropTypes.oneOf([PropTypes.func, PropTypes.node]), + servicesManager: PropTypes.object.isRequired, + extensionManager: PropTypes.object.isRequired, }; export { diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js index 4aa90ffb5..a69e92835 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/measurementTrackingMachine.js @@ -76,6 +76,12 @@ const machineConfiguration = { target: 'idle', }, ], + SET_TRACKED_SERIES: [ + { + target: 'tracking', + actions: ['setTrackedStudyAndMultipleSeries'], + }, + ], }, }, promptTrackNewSeries: { diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js index 657c24e66..57a6188fd 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptBeginTracking.js @@ -6,7 +6,8 @@ const RESPONSE = { SET_STUDY_AND_SERIES: 3, }; -function promptUser(UIViewportDialogService, ctx, evt) { +function promptUser({ servicesManager }, ctx, evt) { + const { UIViewportDialogService } = servicesManager.services; const { viewportIndex, StudyInstanceUID, SeriesInstanceUID } = evt; return new Promise(async function(resolve, reject) { diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js index 4a6f7dcc1..2370ef65d 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewSeries.js @@ -1,3 +1,5 @@ +import createReportAsync from './../../_shared/createReportAsync.js'; + const RESPONSE = { NO_NEVER: -1, CANCEL: 0, @@ -6,8 +8,13 @@ const RESPONSE = { SET_STUDY_AND_SERIES: 3, }; -function promptUser(UIViewportDialogService, ctx, evt) { +function promptUser({ servicesManager, extensionManager }, ctx, evt) { + const { + UIViewportDialogService, + MeasurementService, + } = servicesManager.services; const { viewportIndex, StudyInstanceUID, SeriesInstanceUID } = evt; + const { trackedStudy, trackedSeries } = ctx; return new Promise(async function(resolve, reject) { let promptResult = await _askShouldAddMeasurements( @@ -22,9 +29,19 @@ function promptUser(UIViewportDialogService, ctx, evt) { ); } - // TODO: Hook into @JamesAPetts createReport if (promptResult === RESPONSE.CREATE_REPORT) { - window.alert('CREATE REPORT'); + // TODO -> Eventually deal with multiple dataSources. + // Would need some way of saying which one is the "push" dataSource + const dataSources = extensionManager.getDataSources(); + const dataSource = dataSources[0]; + const measurements = MeasurementService.getMeasurements(); + const trackedMeasurements = measurements.filter( + m => + trackedStudy === m.referenceStudyUID && + trackedSeries.includes(m.referenceSeriesUID) + ); + + createReportAsync(servicesManager, dataSource, trackedMeasurements); } resolve({ diff --git a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.js b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.js index 0f8842a31..62513f0cd 100644 --- a/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.js +++ b/extensions/measurement-tracking/src/contexts/TrackedMeasurementsContext/promptTrackNewStudy.js @@ -1,3 +1,5 @@ +import createReportAsync from './../../_shared/createReportAsync.js'; + const RESPONSE = { NO_NEVER: -1, CANCEL: 0, @@ -6,8 +8,13 @@ const RESPONSE = { SET_STUDY_AND_SERIES: 3, }; -function promptUser(UIViewportDialogService, ctx, evt) { +function promptUser({ servicesManager, extensionManager }, ctx, evt) { + const { + UIViewportDialogService, + MeasurementService, + } = servicesManager.services; const { viewportIndex, StudyInstanceUID, SeriesInstanceUID } = evt; + const { trackedStudy, trackedSeries } = ctx; return new Promise(async function(resolve, reject) { let promptResult = await _askTrackMeasurements( @@ -22,9 +29,19 @@ function promptUser(UIViewportDialogService, ctx, evt) { ); } - // TODO: Hook into @JamesAPetts createReport if (promptResult === RESPONSE.CREATE_REPORT) { - window.alert('CREATE REPORT'); + // TODO -> Eventually deal with multiple dataSources. + // Would need some way of saying which one is the "push" dataSource + const dataSources = extensionManager.getDataSources(); + const dataSource = dataSources[0]; + const measurements = MeasurementService.getMeasurements(); + const trackedMeasurements = measurements.filter( + m => + trackedStudy === m.referenceStudyUID && + trackedSeries.includes(m.referenceSeriesUID) + ); + + createReportAsync(servicesManager, dataSource, trackedMeasurements); } resolve({ diff --git a/extensions/measurement-tracking/src/getContextModule.js b/extensions/measurement-tracking/src/getContextModule.js index 573a00fa2..29acdc402 100644 --- a/extensions/measurement-tracking/src/getContextModule.js +++ b/extensions/measurement-tracking/src/getContextModule.js @@ -4,11 +4,10 @@ import { useTrackedMeasurements, } from './contexts'; -function getContextModule({ servicesManager }) { - const { UIViewportDialogService } = servicesManager.services; +function getContextModule({ servicesManager, extensionManager }) { const BoundTrackedMeasurementsContextProvider = TrackedMeasurementsContextProvider.bind( null, - UIViewportDialogService + { servicesManager, extensionManager } ); return [ diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js index 968a51c1f..1200d2dfa 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js @@ -5,9 +5,7 @@ import { DicomMetadataStore, DICOMSR } from '@ohif/core'; import { useDebounce } from '@hooks'; import ActionButtons from './ActionButtons'; import { useTrackedMeasurements } from '../../getContextModule'; -import cornerstoneTools from 'cornerstone-tools'; -import cornerstone from 'cornerstone-core'; -import dcmjs from 'dcmjs'; +import createReportAsync from './../../_shared/createReportAsync.js'; const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = { key: undefined, // @@ -24,7 +22,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { measurementChangeTimestamp, 200 ); - const { MeasurementService, DisplaySetService } = servicesManager.services; + const { MeasurementService } = servicesManager.services; const [ trackedMeasurements, sendTrackedMeasurementsEvent, @@ -34,9 +32,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { DISPLAY_STUDY_SUMMARY_INITIAL_VALUE ); const [displayMeasurements, setDisplayMeasurements] = useState([]); - // TODO: measurements subscribtion - // Initial? useEffect(() => { const measurements = MeasurementService.getMeasurements(); const filteredMeasurements = measurements.filter( @@ -103,9 +99,24 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { }; }, [MeasurementService, sendTrackedMeasurementsEvent]); - const activeMeasurementItem = 0; + function createReport() { + // TODO -> Eventually deal with multiple dataSources. + // Would need some way of saying which one is the "push" dataSource + const dataSources = extensionManager.getDataSources(); + const dataSource = dataSources[0]; + const measurements = MeasurementService.getMeasurements(); + const trackedMeasurements = measurements.filter( + m => + trackedStudy === m.referenceStudyUID && + trackedSeries.includes(m.referenceSeriesUID) + ); - const exportReport = () => { + return createReportAsync(servicesManager, dataSource, trackedMeasurements); + } + + function exportReport() { + const dataSources = extensionManager.getDataSources(); + const dataSource = dataSources[0]; const measurements = MeasurementService.getMeasurements(); const trackedMeasurements = measurements.filter( m => @@ -115,31 +126,7 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { // TODO -> local download. DICOMSR.downloadReport(trackedMeasurements, dataSource); - }; - - const createReport = async () => { - const measurements = MeasurementService.getMeasurements(); - const trackedMeasurements = measurements.filter( - m => - trackedStudy === m.referenceStudyUID && - trackedSeries.includes(m.referenceSeriesUID) - ); - - const dataSources = extensionManager.getDataSources(); - // TODO -> Eventually deal with multiple dataSources. - // Would need some way of saying which one is the "push" dataSource - const dataSource = dataSources[0]; - - DICOMSR.storeMeasurements( - trackedMeasurements, - dataSource, - naturalizedReport => { - DisplaySetService.makeDisplaySets([naturalizedReport], { - madeInClient: true, - }); - } - ); - }; + } return ( <> @@ -169,7 +156,16 @@ function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { ); } -PanelMeasurementTableTracking.propTypes = {}; +PanelMeasurementTableTracking.propTypes = { + servicesManager: PropTypes.shape({ + services: PropTypes.shape({ + MeasurementService: PropTypes.shape({ + getMeasurements: PropTypes.func.isRequired, + VALUE_TYPES: PropTypes.object.isRequired, + }).isRequired, + }).isRequired, + }).isRequired, +}; // TODO: This could be a MeasurementService mapper function _mapMeasurementToDisplay(measurement, index, types) { @@ -189,9 +185,6 @@ function _mapMeasurementToDisplay(measurement, index, types) { ); const { PixelSpacing, SeriesNumber, InstanceNumber } = instance; - console.log('mapping....', measurement); - console.log(instance); - return { id: index + 1, label: '(empty)', // 'Label short description', @@ -220,15 +213,7 @@ function _getDisplayText( instanceNumber, types ) { - // TODO: determination of shape influences text - // Length: 'xx.x unit (S:x, I:x)' - // Rectangle: 'xx.x x xx.x unit (S:x, I:x)', - // Ellipse? - // Bidirectional? - // Freehand? - const { type, points } = measurement; - const hasPixelSpacing = pixelSpacing !== undefined && Array.isArray(pixelSpacing) && @@ -239,18 +224,16 @@ function _getDisplayText( const unit = hasPixelSpacing ? 'mm' : 'px'; switch (type) { - case types.POLYLINE: + case types.POLYLINE: { const { length } = measurement; - const roundedLength = _round(length, 1); return [ `${roundedLength} ${unit} (S:${seriesNumber}, I:${instanceNumber})`, ]; - - case types.BIDIRECTIONAL: + } + case types.BIDIRECTIONAL: { const { shortestDiameter, longestDiameter } = measurement; - const roundedShortestDiameter = _round(shortestDiameter, 1); const roundedLongestDiameter = _round(longestDiameter, 1); @@ -258,16 +241,19 @@ function _getDisplayText( `l: ${roundedLongestDiameter} ${unit} (S:${seriesNumber}, I:${instanceNumber})`, `s: ${roundedShortestDiameter} ${unit}`, ]; - case types.ELLIPSE: + } + case types.ELLIPSE: { const { area } = measurement; - const roundedArea = _round(area, 1); + return [ `${roundedArea} ${unit}2 (S:${seriesNumber}, I:${instanceNumber})`, ]; - case types.POINT: + } + case types.POINT: { const { text } = measurement; return [`${text} (S:${seriesNumber}, I:${instanceNumber})`]; + } } } diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx index b45cd3691..6616c6306 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx @@ -22,20 +22,33 @@ function PanelStudyBrowserTracking({ // doesn't have to have such an intense shape. This works well enough for now. // Tabs --> Studies --> DisplaySets --> Thumbnails const [{ StudyInstanceUIDs }, dispatchImageViewer] = useImageViewer(); - const [{ activeViewportIndex, viewports }] = useViewportGrid(); + const [ + { activeViewportIndex, viewports }, + viewportGridService, + ] = useViewportGrid(); const [ trackedMeasurements, sendTrackedMeasurementsEvent, ] = useTrackedMeasurements(); const [activeTabName, setActiveTabName] = useState('primary'); - const [expandedStudyInstanceUIDs, setExpandedStudyInstanceUIDs] = useState( - [] - ); + const [expandedStudyInstanceUIDs, setExpandedStudyInstanceUIDs] = useState([ + ...StudyInstanceUIDs, + ]); const [studyDisplayList, setStudyDisplayList] = useState([]); const [displaySets, setDisplaySets] = useState([]); const [thumbnailImageSrcMap, setThumbnailImageSrcMap] = useState({}); const [jumpToDisplaySet, setJumpToDisplaySet] = useState(null); + const onDoubleClickThumbnailHandler = displaySetInstanceUID => { + viewportGridService.setDisplaysetForViewport({ + viewportIndex: activeViewportIndex, + displaySetInstanceUID, + }); + }; + + const activeDisplaySetInstanceUID = + viewports[activeViewportIndex]?.displaySetInstanceUID; + // TODO: Should this be somewhere else? Feels more like a mode "lifecycle" setup/destroy? useEffect(() => { const { unsubscribe } = MeasurementService.subscribe( @@ -287,11 +300,18 @@ function PanelStudyBrowserTracking({ SeriesInstanceUID: displaySet.SeriesInstanceUID, }); }} + onClickThumbnail={() => {}} + onDoubleClickThumbnail={onDoubleClickThumbnailHandler} + activeDisplaySetInstanceUID={activeDisplaySetInstanceUID} /> ); } PanelStudyBrowserTracking.propTypes = { + MeasurementService: PropTypes.shape({ + subscribe: PropTypes.func.isRequired, + EVENTS: PropTypes.object.isRequired, + }).isRequired, DisplaySetService: PropTypes.shape({ EVENTS: PropTypes.object.isRequired, activeDisplaySets: PropTypes.arrayOf(PropTypes.object).isRequired, diff --git a/extensions/vtk/package.json b/extensions/vtk/package.json index 5ef625611..3e33aa678 100644 --- a/extensions/vtk/package.json +++ b/extensions/vtk/package.json @@ -55,9 +55,9 @@ }, "devDependencies": { "@ohif/core": "^2.9.6", - "cornerstone-tools": "4.16.0", "@ohif/ui": "^2.0.0", "cornerstone-core": "^2.3.0", + "cornerstone-tools": "4.16.1", "cornerstone-wado-image-loader": "^3.1.2", "dicom-parser": "^1.8.3", "gh-pages": "^2.0.1", diff --git a/modes/longitudinal/src/index.js b/modes/longitudinal/src/index.js index 2bf100c09..db09204ba 100644 --- a/modes/longitudinal/src/index.js +++ b/modes/longitudinal/src/index.js @@ -20,8 +20,8 @@ export default function mode({ modeConfiguration }) { return { // TODO: We're using this as a route segment // We should not be. - id: 'longitudinal-workflow', - displayName: 'Comparison', + id: 'viewer', + displayName: 'Basic Viewer', validationTags: { study: [], series: [], @@ -95,9 +95,7 @@ export default function mode({ modeConfiguration }) { 'org.ohif.dicom-sr', ], sopClassHandlers: [ohif.sopClassHandler, dicomsr.sopClassHandler], - hotkeys: [ - ...hotkeys.defaults.hotkeyBindings - ] + hotkeys: [...hotkeys.defaults.hotkeyBindings], }; } diff --git a/modes/example/.webpack/webpack.dev.js b/modes/segmentation/.webpack/webpack.dev.js similarity index 100% rename from modes/example/.webpack/webpack.dev.js rename to modes/segmentation/.webpack/webpack.dev.js diff --git a/modes/example/.webpack/webpack.prod.js b/modes/segmentation/.webpack/webpack.prod.js similarity index 100% rename from modes/example/.webpack/webpack.prod.js rename to modes/segmentation/.webpack/webpack.prod.js diff --git a/modes/example/LICENSE b/modes/segmentation/LICENSE similarity index 100% rename from modes/example/LICENSE rename to modes/segmentation/LICENSE diff --git a/modes/example/babel.config.js b/modes/segmentation/babel.config.js similarity index 100% rename from modes/example/babel.config.js rename to modes/segmentation/babel.config.js diff --git a/modes/example/package.json b/modes/segmentation/package.json similarity index 90% rename from modes/example/package.json rename to modes/segmentation/package.json index e98a3601e..66ba594e8 100644 --- a/modes/example/package.json +++ b/modes/segmentation/package.json @@ -1,7 +1,7 @@ { - "name": "@ohif/mode-example", + "name": "@ohif/mode-segmentation", "version": "0.0.1", - "description": "Example mode for OHIF", + "description": "Segmentation mode for OHIF", "author": "OHIF", "license": "MIT", "repository": "OHIF/Viewers", diff --git a/modes/example/src/index.js b/modes/segmentation/src/index.js similarity index 88% rename from modes/example/src/index.js rename to modes/segmentation/src/index.js index 597f179ca..fc56ce4ed 100644 --- a/modes/example/src/index.js +++ b/modes/segmentation/src/index.js @@ -3,8 +3,10 @@ import { hotkeys } from '@ohif/core'; export default function mode({ modeConfiguration }) { return { - id: 'example-mode', - displayName: 'Basic Viewer', + // TODO: Mode uses 'id' for route when it should use `slug`, if provided, and + // the route path + id: 'segmentation', + displayName: 'Segmentation', validationTags: { study: [], series: [], @@ -15,7 +17,7 @@ export default function mode({ modeConfiguration }) { }, routes: [ { - path: 'viewer', + path: 'segmentation', init: ({ servicesManager, extensionManager }) => { const { ToolBarService } = servicesManager.services; ToolBarService.init(extensionManager); @@ -51,7 +53,6 @@ export default function mode({ modeConfiguration }) { return { id: 'org.ohif.default.layoutTemplateModule.viewerLayout', props: { - // named slots leftPanels: ['org.ohif.default.panelModule.seriesList'], rightPanels: ['org.ohif.default.panelModule.measure'], viewports: [ @@ -69,10 +70,8 @@ export default function mode({ modeConfiguration }) { ], extensions: ['org.ohif.default', 'org.ohif.cornerstone'], sopClassHandlers: ['org.ohif.default.sopClassHandlerModule.stack'], - hotkeys: [ - ...hotkeys.defaults.hotkeyBindings - ] + hotkeys: [...hotkeys.defaults.hotkeyBindings], }; } -window.exampleMode = mode({}); +window.segmentationMode = mode({}); diff --git a/modes/example/src/toolbarButtons.js b/modes/segmentation/src/toolbarButtons.js similarity index 100% rename from modes/example/src/toolbarButtons.js rename to modes/segmentation/src/toolbarButtons.js diff --git a/platform/core/package.json b/platform/core/package.json index 34aa934cc..3a16b5b95 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -31,7 +31,7 @@ }, "peerDependencies": { "cornerstone-core": "^2.3.0", - "cornerstone-tools": "4.16.0", + "cornerstone-tools": "4.16.1", "cornerstone-wado-image-loader": "^3.1.2", "dicom-parser": "^1.8.3" }, diff --git a/platform/core/src/DICOMSR/dataExchange.js b/platform/core/src/DICOMSR/dataExchange.js index 8f86f743c..b0dad9044 100644 --- a/platform/core/src/DICOMSR/dataExchange.js +++ b/platform/core/src/DICOMSR/dataExchange.js @@ -79,8 +79,9 @@ const generateReport = measurementData => { * @param {object[]} measurementData An array of measurements from the measurements service * that you wish to serialize. * @param {object} dataSource The dataSource that you wish to use to persist the data. + * @return {object} The naturalized report */ -const storeMeasurements = async (measurementData, dataSource, onSuccess) => { +const storeMeasurements = async (measurementData, dataSource) => { // TODO -> Eventually use the measurements directly and not the dcmjs adapter, // But it is good enough for now whilst we only have cornerstone as a datasource. log.info('[DICOMSR] storeMeasurements'); @@ -90,28 +91,22 @@ const storeMeasurements = async (measurementData, dataSource, onSuccess) => { return Promise.reject({}); } - const naturalizedReport = generateReport(measurementData); - const { StudyInstanceUID } = naturalizedReport; - try { + const naturalizedReport = generateReport(measurementData); + const { StudyInstanceUID } = naturalizedReport; + await dataSource.store.dicom(naturalizedReport); if (StudyInstanceUID) { dataSource.deleteStudyMetadataPromise(StudyInstanceUID); } - if (onSuccess) { - onSuccess(naturalizedReport); - } - - return { - message: 'Measurements saved successfully', - }; + return naturalizedReport; } catch (error) { log.error( `[DICOMSR] Error while saving the measurements: ${error.message}` ); - throw new Error('Error while saving the measurements.'); + throw new Error(error.message || 'Error while saving the measurements.'); } }; diff --git a/platform/ui/src/assets/icons/close.svg b/platform/ui/src/assets/icons/close.svg index 83b259fca..03181c71f 100644 --- a/platform/ui/src/assets/icons/close.svg +++ b/platform/ui/src/assets/icons/close.svg @@ -1,4 +1,4 @@ - + diff --git a/platform/ui/src/components/IconButton/IconButton.jsx b/platform/ui/src/components/IconButton/IconButton.jsx index 290bfa273..38b94a87f 100644 --- a/platform/ui/src/components/IconButton/IconButton.jsx +++ b/platform/ui/src/components/IconButton/IconButton.jsx @@ -113,7 +113,7 @@ const IconButton = ({ }; IconButton.defaultProps = { - onClick: () => {}, + onClick: () => { }, color: 'default', disabled: false, fullWidth: false, diff --git a/platform/ui/src/components/Snackbar/Snackbar.css b/platform/ui/src/components/Snackbar/Snackbar.css new file mode 100644 index 000000000..0f0083def --- /dev/null +++ b/platform/ui/src/components/Snackbar/Snackbar.css @@ -0,0 +1,115 @@ +/* TODO: Create tailwind styles for this component */ +.sb-topLeft { + @apply top-0 left-0 bottom-auto right-auto; +} + +.sb-topCenter { + transform: translateX(-50%); + @apply top-0 bottom-auto left-1/2; +} + +.sb-topRight { + @apply right-0 top-0 left-auto bottom-auto; +} + +.sb-bottomLeft { + @apply right-auto left-0 bottom-0 top-auto; +} + +.sb-bottomCenter { + @apply top-auto bottom-0 left-1/2; + transform: translateX(-50%); +} + +.sb-bottomRight { + margin: 10px 0 0; + @apply top-auto bottom-0 left-auto right-0; +} + +.sb-topLeft .sb-item, +.sb-topCenter .sb-item, +.sb-topRight .sb-item { + margin: 10px 0 0; +} + +.sb-bottomLeft .sb-item, +.sb-bottomCenter .sb-item, +.sb-bottomRight .sb-item { + margin: 0 0 10px; +} + +.sb-closeBtn { + text-shadow: none; + width: 20px; + height: 20px; + right: 5px; + top: 5px; + @apply overflow-hidden opacity-100 rounded-full p-1 bg-white cursor-pointer absolute text-center duration-300 transition-all ease-in-out; +} + +.sb-closeBtn:hover { + background: #fff; +} + +.sb-closeIcon { + @apply w-full relative overflow-hidden h-full block leading-none; +} + +.sb-closeIcon:after, +.sb-closeIcon:before { + content: ' '; + height: 2px; + width: 12px; + @apply duration-300 transition-all ease-in-out block bg-black opacity-100 absolute; +} + +.sb-closeIcon:before { + left: 4px; + top: 3px; + transform: rotate(45deg); + transform-origin: 0px 50%; +} + +.sb-closeIcon:after { + right: 3px; + top: 5px; + transform: rotate(-45deg); + transform-origin: calc(100% - 3px) 50%; +} + +.sb-title { + @apply break-normal text-lg font-bold; +} + +.sb-message { + @apply break-normal text-base; +} + +.sb-item { + animation: fadein 1s; + box-shadow: 0 6px 10px 0 rgba(0, 0, 0, 0.2), 0 1px 18px 0 rgba(0, 0, 0, 0.12), + 0 3px 5px -1px rgba(0, 0, 0, 0.14); + @apply relative p-5 text-white overflow-hidden rounded-md transition-height ease-in-out duration-300; +} + +@keyframes fadein { + from { + top: 30px; + @apply opacity-0; + } + to { + @apply opacity-100 top-0; + } +} + +/* Internet Explorer */ +@-ms-keyframes fadein { + from { + top: 30px; + @apply opacity-0; + } + to { + @apply opacity-100; + top: 0; + } +} diff --git a/platform/ui/src/components/Snackbar/SnackbarContainer.jsx b/platform/ui/src/components/Snackbar/SnackbarContainer.jsx index eb0e83fb2..40c76fc00 100644 --- a/platform/ui/src/components/Snackbar/SnackbarContainer.jsx +++ b/platform/ui/src/components/Snackbar/SnackbarContainer.jsx @@ -2,16 +2,18 @@ import React from 'react'; import SnackbarItem from './SnackbarItem'; import { useSnackbar } from '../../contextProviders'; +import './Snackbar.css'; + const SnackbarContainer = () => { const { snackbarItems, hide } = useSnackbar(); - const renderItem = item => { - return ; - }; - - if (!snackbarItems) { - return null; - } + const renderItem = item => ( + + ); const renderItems = () => { const items = { @@ -23,11 +25,9 @@ const SnackbarContainer = () => { bottomRight: [], }; - snackbarItems.map(item => { - items[item.position].push(item); - }); + snackbarItems.forEach(item => items[item.position].push(item)); - return ( + return snackbarItems && (
{Object.keys(items).map(pos => { if (!items[pos].length) { @@ -35,7 +35,7 @@ const SnackbarContainer = () => { } return ( -
+
{items[pos].map((item, index) => (
{renderItem(item)}
))} diff --git a/platform/ui/src/components/Snackbar/SnackbarItem.jsx b/platform/ui/src/components/Snackbar/SnackbarItem.jsx index abeef2ccf..1c45790f2 100644 --- a/platform/ui/src/components/Snackbar/SnackbarItem.jsx +++ b/platform/ui/src/components/Snackbar/SnackbarItem.jsx @@ -1,25 +1,38 @@ import React, { useEffect } from 'react'; +import classNames from 'classnames'; + +import SnackbarTypes from './SnackbarTypes'; const SnackbarItem = ({ options, onClose }) => { - const handleClose = () => { - onClose(options.id); - }; + const handleClose = () => onClose(options.id); useEffect(() => { if (options.autoClose) { - setTimeout(() => { - handleClose(); - }, options.duration); + setTimeout(() => handleClose(), options.duration); } }, []); + const typeClasses = { + [SnackbarTypes.INFO]: 'bg-primary-active', + [SnackbarTypes.WARNING]: 'bg-yellow-600', + [SnackbarTypes.SUCCESS]: 'bg-green-600', + [SnackbarTypes.ERROR]: 'bg-red-600' + }; + + const hidden = 'duration-300 transition-all ease-in-out h-0 opacity-0 pt-0 mb-0 pb-0'; + return ( -
- - x +
+ + x - {options.title &&
{options.title}
} - {options.message &&
{options.message}
} + {options.title &&
{options.title}
} + {options.message &&
{options.message}
}
); }; diff --git a/platform/ui/src/components/StudyBrowser/StudyBrowser.jsx b/platform/ui/src/components/StudyBrowser/StudyBrowser.jsx index 048489e70..c5110f5c7 100644 --- a/platform/ui/src/components/StudyBrowser/StudyBrowser.jsx +++ b/platform/ui/src/components/StudyBrowser/StudyBrowser.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; @@ -25,10 +25,10 @@ const StudyBrowser = ({ onClickTab, onClickStudy, onClickThumbnail, + onDoubleClickThumbnail, onClickUntrack, + activeDisplaySetInstanceUID, }) => { - const [thumbnailActive, setThumbnailActive] = useState(null); - const getTabContent = () => { const tabData = tabs.find(tab => tab.name === activeTabName); @@ -58,18 +58,10 @@ const StudyBrowser = ({ {isExpanded && displaySets && ( { - setThumbnailActive( - displaySetInstanceUID === thumbnailActive - ? null - : displaySetInstanceUID - ); - onClickThumbnail(displaySetInstanceUID); - }} - onClickUntrack={displaySetInstanceUID => { - onClickUntrack(displaySetInstanceUID); - }} + activeDisplaySetInstanceUID={activeDisplaySetInstanceUID} + onThumbnailClick={onClickThumbnail} + onThumbnailDoubleClick={onDoubleClickThumbnail} + onClickUntrack={onClickUntrack} /> )} @@ -118,9 +110,11 @@ StudyBrowser.propTypes = { onClickTab: PropTypes.func.isRequired, onClickStudy: PropTypes.func, onClickThumbnail: PropTypes.func, + onDoubleClickThumbnail: PropTypes.func, onClickUntrack: PropTypes.func, activeTabName: PropTypes.string.isRequired, expandedStudyInstanceUIDs: PropTypes.arrayOf(PropTypes.string).isRequired, + activeDisplaySetInstanceUID: PropTypes.string, tabs: PropTypes.arrayOf( PropTypes.shape({ name: PropTypes.string.isRequired, @@ -173,6 +167,7 @@ StudyBrowser.defaultProps = { onClickTab: noop, onClickStudy: noop, onClickThumbnail: noop, + onDoubleClickThumbnail: noop, onClickUntrack: noop, }; diff --git a/platform/ui/src/components/Thumbnail/Thumbnail.jsx b/platform/ui/src/components/Thumbnail/Thumbnail.jsx index 7813928a0..ec378ae09 100644 --- a/platform/ui/src/components/Thumbnail/Thumbnail.jsx +++ b/platform/ui/src/components/Thumbnail/Thumbnail.jsx @@ -1,9 +1,9 @@ -import React from 'react'; +import React, { useRef } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { useDrag } from 'react-dnd'; -// import { Icon } from '@ohif/ui'; +import blurHandlerListener from '../../utils/blurHandlerListener'; /** * @@ -19,6 +19,7 @@ const Thumbnail = ({ dragData, isActive, onClick, + onDoubleClick, }) => { // TODO: We should wrap our thumbnail to create a "DraggableThumbnail", as // this will still allow for "drag", even if there is no drop target for the @@ -30,52 +31,58 @@ const Thumbnail = ({ }, }); + const thumbnailElement = useRef(null); + return (
blurHandlerListener(thumbnailElement)} + ref={thumbnailElement} className={classnames( className, - 'flex flex-col flex-1 px-3 mb-8 cursor-pointer outline-none' + 'flex flex-col flex-1 px-3 mb-8 cursor-pointer outline-none group' )} id={`thumbnail-${displaySetInstanceUID}`} onClick={onClick} - onKeyDown={onClick} + onDoubleClick={onDoubleClick} role="button" tabIndex="0" > -
- {imageSrc ? ( - {imageAltText} - ) : ( -
{imageAltText}
- )} -
-
-
- {'S: '} - {seriesNumber} +
+
+ {imageSrc ? ( + {imageAltText} + ) : ( +
{imageAltText}
+ )}
-
- {numInstances} +
+
+ {'S: '} + {seriesNumber} +
+
+ {numInstances} +
+
{description}
-
{description}
); }; Thumbnail.propTypes = { + displaySetInstanceUID: PropTypes.string.isRequired, className: PropTypes.string, imageSrc: PropTypes.string, /** @@ -95,6 +102,7 @@ Thumbnail.propTypes = { numInstances: PropTypes.number.isRequired, isActive: PropTypes.bool.isRequired, onClick: PropTypes.func.isRequired, + onDoubleClick: PropTypes.func.isRequired, }; Thumbnail.defaultProps = { diff --git a/platform/ui/src/components/ThumbnailList/ThumbnailList.jsx b/platform/ui/src/components/ThumbnailList/ThumbnailList.jsx index 0b1febd82..80e87f667 100644 --- a/platform/ui/src/components/ThumbnailList/ThumbnailList.jsx +++ b/platform/ui/src/components/ThumbnailList/ThumbnailList.jsx @@ -5,8 +5,9 @@ import { Thumbnail, ThumbnailNoImage, ThumbnailTracked } from '@ohif/ui'; const ThumbnailList = ({ thumbnails, - thumbnailActive, + activeDisplaySetInstanceUID, onThumbnailClick, + onThumbnailDoubleClick, onClickUntrack, }) => { return ( @@ -26,7 +27,8 @@ const ThumbnailList = ({ imageSrc, imageAltText, }) => { - const isActive = thumbnailActive === displaySetInstanceUID; + const isActive = + activeDisplaySetInstanceUID === displaySetInstanceUID; switch (componentType) { case 'thumbnail': @@ -43,6 +45,9 @@ const ThumbnailList = ({ viewportIdentificator={viewportIdentificator} isActive={isActive} onClick={() => onThumbnailClick(displaySetInstanceUID)} + onDoubleClick={() => + onThumbnailDoubleClick(displaySetInstanceUID) + } /> ); case 'thumbnailTracked': @@ -60,12 +65,16 @@ const ThumbnailList = ({ isTracked={isTracked} isActive={isActive} onClick={() => onThumbnailClick(displaySetInstanceUID)} + onDoubleClick={() => + onThumbnailDoubleClick(displaySetInstanceUID) + } onClickUntrack={() => onClickUntrack(displaySetInstanceUID)} /> ); case 'thumbnailNoImage': return ( onThumbnailClick(displaySetInstanceUID)} + onDoubleClick={() => + onThumbnailDoubleClick(displaySetInstanceUID) + } /> ); default: @@ -114,8 +126,9 @@ ThumbnailList.propTypes = { }), }) ), - thumbnailActive: PropTypes.string, - onThumbnailClick: PropTypes.func, + activeDisplaySetInstanceUID: PropTypes.string, + onThumbnailClick: PropTypes.func.isRequired, + onThumbnailDoubleClick: PropTypes.func.isRequired, onClickUntrack: PropTypes.func.isRequired, }; diff --git a/platform/ui/src/components/ThumbnailNoImage/ThumbnailNoImage.jsx b/platform/ui/src/components/ThumbnailNoImage/ThumbnailNoImage.jsx index 205a7af37..c01e619f3 100644 --- a/platform/ui/src/components/ThumbnailNoImage/ThumbnailNoImage.jsx +++ b/platform/ui/src/components/ThumbnailNoImage/ThumbnailNoImage.jsx @@ -1,9 +1,9 @@ -import React from 'react'; -import PropTypes from 'prop-types'; +import React, { useRef } from 'react'; import classnames from 'classnames'; +import PropTypes from 'prop-types'; import { useDrag } from 'react-dnd'; - import { Icon } from '@ohif/ui'; +import blurHandlerListener from '../../utils/blurHandlerListener'; const ThumbnailNoImage = ({ displaySetInstanceUID, @@ -11,7 +11,9 @@ const ThumbnailNoImage = ({ seriesDate, modality, onClick, + onDoubleClick, dragData, + isActive, }) => { const [collectedProps, drag, dragPreview] = useDrag({ item: { ...dragData }, @@ -20,26 +22,34 @@ const ThumbnailNoImage = ({ }, }); + const thumbnailElement = useRef(null); + return (
blurHandlerListener(thumbnailElement)} + className={classnames( + 'flex flex-row flex-1 px-4 py-3 cursor-pointer outline-none border-transparent hover:border-blue-300 focus:border-blue-300 rounded', + isActive ? 'border-2 border-primary-light' : 'border' + )} id={`thumbnail-${displaySetInstanceUID}`} onClick={onClick} - onKeyDown={onClick} + onDoubleClick={onDoubleClick} role="button" tabIndex="0" > -
-
- -
- {modality} +
+
+
+ +
+ {modality} +
+ {seriesDate} +
+
+ {description}
- {seriesDate} -
-
- {description}
@@ -47,10 +57,24 @@ const ThumbnailNoImage = ({ }; ThumbnailNoImage.propTypes = { + displaySetInstanceUID: PropTypes.string.isRequired, + /** + * Data the thumbnail should expose to a receiving drop target. Use a matching + * `dragData.type` to identify which targets can receive this draggable item. + * If this is not set, drag-n-drop will be disabled for this thumbnail. + * + * Ref: https://react-dnd.github.io/react-dnd/docs/api/use-drag#specification-object-members + */ + dragData: PropTypes.shape({ + /** Must match the "type" a dropTarget expects */ + type: PropTypes.string.isRequired, + }), description: PropTypes.string.isRequired, modality: PropTypes.string.isRequired, seriesDate: PropTypes.string.isRequired, onClick: PropTypes.func.isRequired, + onDoubleClick: PropTypes.func.isRequired, + isActive: PropTypes.bool.isRequired, }; export default ThumbnailNoImage; diff --git a/platform/ui/src/components/ThumbnailTracked/ThumbnailTracked.jsx b/platform/ui/src/components/ThumbnailTracked/ThumbnailTracked.jsx index 6715a77d3..2d8e09e05 100644 --- a/platform/ui/src/components/ThumbnailTracked/ThumbnailTracked.jsx +++ b/platform/ui/src/components/ThumbnailTracked/ThumbnailTracked.jsx @@ -14,6 +14,7 @@ const ThumbnailTracked = ({ numInstances, dragData, onClick, + onDoubleClick, onClickUntrack, viewportIdentificator, isTracked, @@ -72,6 +73,7 @@ const ThumbnailTracked = ({ )}
); @@ -97,6 +100,7 @@ ThumbnailTracked.propTypes = { /** Must match the "type" a dropTarget expects */ type: PropTypes.string.isRequired, }), + displaySetInstanceUID: PropTypes.string.isRequired, className: PropTypes.string, imageSrc: PropTypes.string, imageAltText: PropTypes.string, @@ -104,6 +108,7 @@ ThumbnailTracked.propTypes = { seriesNumber: PropTypes.number.isRequired, numInstances: PropTypes.number.isRequired, onClick: PropTypes.func.isRequired, + onDoubleClick: PropTypes.func.isRequired, onClickUntrack: PropTypes.func.isRequired, viewportIdentificator: PropTypes.string, isTracked: PropTypes.bool, diff --git a/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx b/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx index 58a8ce142..f856d98b8 100644 --- a/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx +++ b/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx @@ -24,6 +24,7 @@ const ViewportActionBar = ({ // It shouldn't care that a tracking mode or SR exists. // Things like the right/left buttons should be made into smaller // Components you can compose. + // OHIF-200 ticket. const { label, diff --git a/platform/ui/src/contextProviders/DialogProvider.css b/platform/ui/src/contextProviders/DialogProvider.css new file mode 100644 index 000000000..09438e95b --- /dev/null +++ b/platform/ui/src/contextProviders/DialogProvider.css @@ -0,0 +1,8 @@ +/* TODO: Find a better way to set the cursor for all contents of dialog. */ +.DraggableItem.draggable div { + cursor: grab !important; +} + +.DraggableItem.draggable.dragging div { + cursor: grabbing !important; +} diff --git a/platform/ui/src/contextProviders/DialogProvider.jsx b/platform/ui/src/contextProviders/DialogProvider.jsx index eb3c207cb..58ed46432 100644 --- a/platform/ui/src/contextProviders/DialogProvider.jsx +++ b/platform/ui/src/contextProviders/DialogProvider.jsx @@ -5,11 +5,14 @@ import React, { useCallback, useEffect, } from 'react'; + import PropTypes from 'prop-types'; import Draggable from 'react-draggable'; import classNames from 'classnames'; + import { utils } from '@ohif/core'; +import './DialogProvider.css'; const DialogContext = createContext(null); @@ -150,6 +153,7 @@ const DialogProvider = ({ children, service }) => { onStart, onStop, onDrag, + showOverlay, } = dialog; let position = @@ -158,13 +162,13 @@ const DialogProvider = ({ children, service }) => { position = centerPositions.find(position => position.id === id); } - return ( + const dragableItem = () => ( { const e = event || window.event; const target = e.target || e.srcElement; @@ -215,6 +219,21 @@ const DialogProvider = ({ children, service }) => {
); + + const withOverlay = component => { + const background = 'bg-black bg-opacity-50'; + const overlay = 'fixed z-50 left-0 top-0 w-full h-full overflow-auto'; + return ( +
+ {component} +
+ ); + }; + + return showOverlay ? withOverlay(dragableItem()) : dragableItem(); }); /** @@ -236,13 +255,11 @@ const DialogProvider = ({ children, service }) => { return ( -
- {dialogs.some(dialog => dialog.showOverlay) ? ( -
{renderDialogs()}
- ) : ( - renderDialogs() - )} -
+ {!isEmpty() && +
+ {renderDialogs()} +
+ } {children}
); diff --git a/platform/ui/src/contextProviders/ViewportGridProvider.jsx b/platform/ui/src/contextProviders/ViewportGridProvider.jsx index fe77ef0a0..a522ea165 100644 --- a/platform/ui/src/contextProviders/ViewportGridProvider.jsx +++ b/platform/ui/src/contextProviders/ViewportGridProvider.jsx @@ -10,7 +10,11 @@ import PropTypes from 'prop-types'; const DEFAULT_STATE = { numRows: 1, numCols: 1, - viewports: [], + viewports: [ + // { + // displaySetInstanceUID: string, + // } + ], activeViewportIndex: 0, }; @@ -19,8 +23,9 @@ export const ViewportGridContext = createContext(DEFAULT_STATE); export function ViewportGridProvider({ children, service }) { const viewportGridReducer = (state, action) => { switch (action.type) { - case 'SET_ACTIVE_VIEWPORT_INDEX': + case 'SET_ACTIVE_VIEWPORT_INDEX': { return { ...state, ...{ activeViewportIndex: action.payload } }; + } case 'SET_DISPLAYSET_FOR_VIEWPORT': { const { viewportIndex, displaySetInstanceUID } = action.payload; const viewports = state.viewports.slice(); @@ -55,7 +60,7 @@ export function ViewportGridProvider({ children, service }) { const [viewportGridState, dispatch] = useReducer( viewportGridReducer, - DEFAULT_STATE, + DEFAULT_STATE ); const getState = useCallback(() => viewportGridState, [viewportGridState]); diff --git a/platform/ui/src/utils/blurHandlerListener.js b/platform/ui/src/utils/blurHandlerListener.js new file mode 100644 index 000000000..4ffba68c6 --- /dev/null +++ b/platform/ui/src/utils/blurHandlerListener.js @@ -0,0 +1,10 @@ +export default element => { + const handleClickOutside = event => { + if (element.current && !element.current.contains(event.target)) { + element.current.blur(); + document.removeEventListener('mousedown', handleClickOutside); + } + }; + + document.addEventListener('mousedown', handleClickOutside); +}; diff --git a/platform/ui/tailwind.config.js b/platform/ui/tailwind.config.js index b01fd7e1a..967575810 100644 --- a/platform/ui/tailwind.config.js +++ b/platform/ui/tailwind.config.js @@ -329,6 +329,7 @@ module.exports = { auto: 'auto', full: '100%', viewport: '0.5rem', + '1/2': '50%', 'viewport-scrollbar': '1.3rem' }, letterSpacing: { @@ -683,6 +684,7 @@ module.exports = { transitionProperty: { none: 'none', all: 'all', + 'height': 'height', default: 'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform', colors: 'background-color, border-color, color, fill, stroke', @@ -719,7 +721,7 @@ module.exports = { backgroundRepeat: ['responsive'], backgroundSize: ['responsive'], borderCollapse: ['responsive'], - borderColor: ['responsive', 'hover', 'focus', 'active'], + borderColor: ['responsive', 'hover', 'focus', 'active', 'group-focus'], borderRadius: ['responsive', 'focus', 'first', 'last'], borderStyle: ['responsive', 'focus'], borderWidth: ['responsive', 'focus', 'first', 'last'], diff --git a/platform/viewer/package.json b/platform/viewer/package.json index 9364101ef..629e5e304 100644 --- a/platform/viewer/package.json +++ b/platform/viewer/package.json @@ -55,9 +55,9 @@ "@ohif/extension-dicom-html": "^1.1.0", "@ohif/extension-dicom-microscopy": "^0.50.6", "@ohif/extension-dicom-pdf": "^1.0.1", + "@ohif/extension-dicom-sr": "^0.0.1", "@ohif/extension-lesion-tracker": "^0.2.0", "@ohif/extension-measurement-tracking": "^0.0.1", - "@ohif/extension-dicom-sr": "^0.0.1", "@ohif/extension-vtk": "^1.5.6", "@ohif/i18n": "^0.52.8", "@ohif/mode-longitudinal": "^0.0.1", @@ -67,7 +67,7 @@ "classnames": "^2.2.6", "core-js": "^3.2.1", "cornerstone-math": "^0.1.8", - "cornerstone-tools": "4.16.0", + "cornerstone-tools": "4.16.1", "cornerstone-wado-image-loader": "^3.1.2", "dcmjs": "0.14.0", "dicom-parser": "^1.8.3", diff --git a/platform/viewer/src/App.jsx b/platform/viewer/src/App.jsx index 1cb815d60..1e72bd772 100644 --- a/platform/viewer/src/App.jsx +++ b/platform/viewer/src/App.jsx @@ -20,8 +20,8 @@ import createRoutes from './routes'; import appInit from './appInit.js'; // TODO: Temporarily for testing -import '@ohif/mode-example'; import '@ohif/mode-longitudinal'; +import '@ohif/mode-segmentation'; /** * ENV Variable to determine routing behavior @@ -50,7 +50,7 @@ function App({ config, defaultExtensions }) { dataSources, extensionManager, servicesManager, - hotkeysManager + hotkeysManager, }); const { UIDialogService, diff --git a/platform/viewer/src/appInit.js b/platform/viewer/src/appInit.js index eb14613ce..3c35dd487 100644 --- a/platform/viewer/src/appInit.js +++ b/platform/viewer/src/appInit.js @@ -71,8 +71,8 @@ function appInit(appConfigOrFunc, defaultExtensions) { // TODO: Remove this if (!appConfig.modes.length) { - appConfig.modes.push(window.exampleMode); appConfig.modes.push(window.longitudinalMode); + appConfig.modes.push(window.segmentationMode); } return { @@ -80,7 +80,7 @@ function appInit(appConfigOrFunc, defaultExtensions) { commandsManager, extensionManager, servicesManager, - hotkeysManager + hotkeysManager, }; } diff --git a/yarn.lock b/yarn.lock index 7cb2da6e7..ee59610f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6755,10 +6755,10 @@ cornerstone-math@^0.1.8: resolved "https://registry.yarnpkg.com/cornerstone-math/-/cornerstone-math-0.1.8.tgz#68ab1f9e4fdcd7c5cb23a0d2eb4263f9f894f1c5" integrity sha512-x7NEQHBtVG7j1yeyj/aRoKTpXv1Vh2/H9zNLMyqYJDtJkNng8C4Q8M3CgZ1qer0Yr7eVq2x+Ynmj6kfOm5jXKw== -cornerstone-tools@4.16.0: - version "4.16.0" - resolved "https://registry.yarnpkg.com/cornerstone-tools/-/cornerstone-tools-4.16.0.tgz#af3d32d13722b97bec258492642e622312280196" - integrity sha512-kUhuSb2Ixpd2hgbdem+740rnN4hmoxzcOBNaUcsizFRWjMAsgc0yUxBFwLl0mIs811mefq79JOL+juwRA8T3Tg== +cornerstone-tools@4.16.1: + version "4.16.1" + resolved "https://registry.yarnpkg.com/cornerstone-tools/-/cornerstone-tools-4.16.1.tgz#e38f471c8fd30c6d25aab9a7995914542f3a87c5" + integrity sha512-c5gww9Px97R/avFXlS93uyr93syFbhHAHemEykO2Ot/hVQN8EDQTOmsjR/mRRSnVdLlNug4w16obLtWWAl23KQ== dependencies: "@babel/runtime" "7.1.2" cornerstone-math "0.1.7"