diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index d1b8588ad..6865c3beb 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -34,7 +34,7 @@ "cornerstone-core": "^2.3.0", "cornerstone-math": "^0.1.8", "cornerstone-tools": "4.16.0", - "dcmjs": "^0.12.3", + "dcmjs": "0.14.0", "cornerstone-wado-image-loader": "^3.1.2", "dicom-parser": "^1.8.3", "hammerjs": "^2.0.8", diff --git a/extensions/cornerstone/src/CornerstoneViewportDownloadForm.js b/extensions/cornerstone/src/CornerstoneViewportDownloadForm.js index babf3bf69..7582c0960 100644 --- a/extensions/cornerstone/src/CornerstoneViewportDownloadForm.js +++ b/extensions/cornerstone/src/CornerstoneViewportDownloadForm.js @@ -145,8 +145,4 @@ CornerstoneViewportDownloadForm.propTypes = { activeViewportIndex: PropTypes.number.isRequired, }; -// export default CornerstoneViewportDownloadForm; - -export default function HelloWorld() { - return
Hello World
; -} +export default CornerstoneViewportDownloadForm; diff --git a/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js b/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js index b1d9f2f44..7d2c1c3b2 100644 --- a/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js +++ b/extensions/cornerstone/src/utils/measurementServiceMappings/ArrowAnnotate.js @@ -1,5 +1,4 @@ import SUPPORTED_TOOLS from './constants/supportedTools'; -import getHandlesFromPoints from './utils/getHandlesFromPoints'; import getPointsFromHandles from './utils/getPointsFromHandles'; import getSOPInstanceAttributes from './utils/getSOPInstanceAttributes'; diff --git a/extensions/default/package.json b/extensions/default/package.json index e9633cdc1..09342a611 100644 --- a/extensions/default/package.json +++ b/extensions/default/package.json @@ -32,7 +32,7 @@ "react": "^16.13.1", "react-dom": "^16.13.1", "webpack": "^4.0.0", - "dcmjs": "^0.12.4" + "dcmjs": "0.14.0" }, "dependencies": { "@babel/runtime": "7.7.6" diff --git a/extensions/default/src/DicomWebDataSource/index.js b/extensions/default/src/DicomWebDataSource/index.js index c832ee4a9..93a7b1b2f 100644 --- a/extensions/default/src/DicomWebDataSource/index.js +++ b/extensions/default/src/DicomWebDataSource/index.js @@ -12,9 +12,16 @@ import getImageId from './utils/getImageId'; import * as dcmjs from 'dcmjs'; import { retrieveStudyMetadata } from './retrieveStudyMetadata.js'; -const { naturalizeDataset } = dcmjs.data.DicomMetaDictionary; +const { DicomMetaDictionary, DicomDict } = dcmjs.data; + +const { naturalizeDataset, denaturalizeDataset } = DicomMetaDictionary; const { urlUtil } = utils; +const ImplementationClassUID = + '2.25.270695996825855179949881587723571202391.2.0.0'; +const ImplementationVersionName = 'OHIF-VIEWER-2.0.0'; +const EXPLICIT_VR_LITTLE_ENDIAN = '1.2.840.10008.1.2.1'; + /** * * @param {string} name - Data source name @@ -128,6 +135,32 @@ function createDicomWebApi(dicomWebConfig) { }, }, }, + store: { + dicom: async dataset => { + const meta = { + FileMetaInformationVersion: + dataset._meta.FileMetaInformationVersion.Value, + MediaStorageSOPClassUID: dataset.SOPClassUID, + MediaStorageSOPInstanceUID: dataset.SOPInstanceUID, + TransferSyntaxUID: EXPLICIT_VR_LITTLE_ENDIAN, + ImplementationClassUID, + ImplementationVersionName, + }; + + const denaturalized = denaturalizeDataset(meta); + const dicomDict = new DicomDict(denaturalized); + + dicomDict.dict = denaturalizeDataset(dataset); + + const part10Buffer = dicomDict.write(); + + const options = { + datasets: [part10Buffer], + }; + + await wadoDicomWebClient.storeInstances(options); + }, + }, retrieveSeriesMetadata: async ({ StudyInstanceUID } = {}) => { if (!StudyInstanceUID) { throw new Error( diff --git a/extensions/default/src/DicomWebDataSource/qido.js b/extensions/default/src/DicomWebDataSource/qido.js index 83613dc0c..00a390424 100644 --- a/extensions/default/src/DicomWebDataSource/qido.js +++ b/extensions/default/src/DicomWebDataSource/qido.js @@ -22,7 +22,7 @@ * | limit | {number} | * | offset | {number} | */ -import { DICOMWeb } from '@ohif/core'; +import { DICOMWeb, utils } from '@ohif/core'; const { getString, getName, getModalities } = DICOMWeb; @@ -50,7 +50,7 @@ function processResults(qidoStudies) { time: getString(qidoStudy['00080030']), // HHmmss.SSS (24-hour, minutes, seconds, fractional seconds) accession: getString(qidoStudy['00080050']) || '', // short string, probably a number? mrn: getString(qidoStudy['00100020']) || '', // medicalRecordNumber - patientName: getName(qidoStudy['00100010']) || '', + patientName: utils.formatPN(getName(qidoStudy['00100010'])) || '', instances: Number(getString(qidoStudy['00201208'])) || 0, // number description: getString(qidoStudy['00081030']) || '', modalities: diff --git a/extensions/default/src/Panels/PanelStudyBrowser.jsx b/extensions/default/src/Panels/PanelStudyBrowser.jsx index e2647aca6..221395926 100644 --- a/extensions/default/src/Panels/PanelStudyBrowser.jsx +++ b/extensions/default/src/Panels/PanelStudyBrowser.jsx @@ -127,7 +127,6 @@ function PanelStudyBrowser({ changedDisplaySets, thumbnailImageSrcMap ); - setDisplaySets(mappedDisplaySets); } ); @@ -152,11 +151,11 @@ function PanelStudyBrowser({ ); const updatedExpandedStudyInstanceUIDs = shouldCollapseStudy ? // eslint-disable-next-line prettier/prettier - [ - ...expandedStudyInstanceUIDs.filter( - stdyUid => stdyUid !== StudyInstanceUID - ), - ] + [ + ...expandedStudyInstanceUIDs.filter( + stdyUid => stdyUid !== StudyInstanceUID + ), + ] : [...expandedStudyInstanceUIDs, StudyInstanceUID]; setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs); diff --git a/extensions/default/src/Panels/WrappedPanelStudyBrowser.jsx b/extensions/default/src/Panels/WrappedPanelStudyBrowser.jsx index c3bb27d01..e79deea68 100644 --- a/extensions/default/src/Panels/WrappedPanelStudyBrowser.jsx +++ b/extensions/default/src/Panels/WrappedPanelStudyBrowser.jsx @@ -20,7 +20,7 @@ function WrappedPanelStudyBrowser({ }) { // TODO: This should be made available a different way; route should have // already determined our datasource - const dataSource = extensionManager.getDataSources('dicomweb')[0]; + const dataSource = extensionManager.getDataSources()[0]; const _getStudiesForPatientByStudyInstanceUID = getStudiesForPatientByStudyInstanceUID.bind( null, dataSource diff --git a/extensions/default/src/Toolbar/ToolbarLayoutSelector.jsx b/extensions/default/src/Toolbar/ToolbarLayoutSelector.jsx index 36236b93b..b5a4253ab 100644 --- a/extensions/default/src/Toolbar/ToolbarLayoutSelector.jsx +++ b/extensions/default/src/Toolbar/ToolbarLayoutSelector.jsx @@ -5,22 +5,40 @@ import { useViewportGrid, } from '@ohif/ui'; +const DEFAULT_LAYOUT = { + type: 'SET_LAYOUT', + payload: { + numCols: 1, + numRows: 1, + }, +}; + function LayoutSelector() { const [isOpen, setIsOpen] = useState(false); const [viewportGridState, dispatch] = useViewportGrid(); - useEffect(() => { - function closeOnOutsideClick() { - if (isOpen) { - setIsOpen(false); - } + const closeOnOutsideClick = () => { + if (isOpen) { + setIsOpen(false); } + }; + + useEffect(() => { window.addEventListener('click', closeOnOutsideClick); return () => { window.removeEventListener('click', closeOnOutsideClick); }; }, [isOpen]); + useEffect(() => { + /* Reset to default layout when component unmounts */ + return () => { + dispatch(DEFAULT_LAYOUT); + }; + }, []); + + const onClickHandler = () => setIsOpen(!isOpen); + const DropdownContent = isOpen ? OHIFLayoutSelector : null; return ( @@ -28,9 +46,7 @@ function LayoutSelector() { id="Layout" label="Grid Layout" icon="tool-layout" - onClick={() => { - setIsOpen(!isOpen); - }} + onClick={onClickHandler} dropdownContent={ DropdownContent !== null && ( diff --git a/extensions/measurement-tracking/package.json b/extensions/measurement-tracking/package.json index 7d08ecdbd..86bad62c0 100644 --- a/extensions/measurement-tracking/package.json +++ b/extensions/measurement-tracking/package.json @@ -28,11 +28,12 @@ }, "peerDependencies": { "@ohif/core": "^0.50.0", - "dcmjs": "^0.12.4", + "dcmjs": "0.14.0", "prop-types": "^15.6.2", "react": "^16.13.1", "react-dom": "^16.13.1", - "webpack": "^4.0.0" + "webpack": "^4.0.0", + "cornerstone-tools": "4.15.1" }, "dependencies": { "@babel/runtime": "7.7.6", diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/ActionButtons.jsx b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/ActionButtons.jsx index 0e3e8a3b6..6d239cf81 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/ActionButtons.jsx +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/ActionButtons.jsx @@ -2,10 +2,10 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Button, ButtonGroup, Icon, IconButton } from '@ohif/ui'; -function ActionButtons() { +function ActionButtons({ onExportClick, onCreateReportClick }) { return ( - alert('Export')}> + @@ -34,4 +34,14 @@ function ActionButtons() { ); } +ActionButtons.propTypes = { + onExportClick: PropTypes.func, + onCreateReportClick: PropTypes.func, +}; + +ActionButtons.defaultProps = { + onExportClick: () => alert('Export'), + onCreateReportClick: () => alert('Create Report'), +}; + export default ActionButtons; diff --git a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js index edfd44aa1..10184832a 100644 --- a/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js +++ b/extensions/measurement-tracking/src/panels/PanelMeasurementTableTracking/index.js @@ -1,10 +1,13 @@ import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import { StudySummary, MeasurementTable } from '@ohif/ui'; -import { DicomMetadataStore } from '@ohif/core'; +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'; const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = { key: undefined, // @@ -13,7 +16,7 @@ const DISPLAY_STUDY_SUMMARY_INITIAL_VALUE = { description: undefined, // 'CHEST/ABD/PELVIS W CONTRAST', }; -function PanelMeasurementTableTracking({ servicesManager, commandsManager }) { +function PanelMeasurementTableTracking({ servicesManager, extensionManager }) { const [measurementChangeTimestamp, setMeasurementsUpdated] = useState( Date.now().toString() ); @@ -102,6 +105,35 @@ function PanelMeasurementTableTracking({ servicesManager, commandsManager }) { const activeMeasurementItem = 0; + const onExportClick = () => { + const measurements = MeasurementService.getMeasurements(); + const trackedMeasurements = measurements.filter( + m => + trackedStudy === m.referenceStudyUID && + trackedSeries.includes(m.referenceSeriesUID) + ); + + // TODO -> local download. + DICOMSR.downloadReport(trackedMeasurements, dataSource); + }; + + const onCreateReportClick = () => { + 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); + }; + return ( <>
@@ -121,7 +153,10 @@ function PanelMeasurementTableTracking({ servicesManager, commandsManager }) { />
- +
); diff --git a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx index d33724702..2e9f45429 100644 --- a/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx +++ b/extensions/measurement-tracking/src/panels/PanelStudyBrowserTracking/PanelStudyBrowserTracking.jsx @@ -201,10 +201,10 @@ function PanelStudyBrowserTracking({ ); const updatedExpandedStudyInstanceUIDs = shouldCollapseStudy ? [ - ...expandedStudyInstanceUIDs.filter( - stdyUid => stdyUid !== StudyInstanceUID - ), - ] + ...expandedStudyInstanceUIDs.filter( + stdyUid => stdyUid !== StudyInstanceUID + ), + ] : [...expandedStudyInstanceUIDs, StudyInstanceUID]; setExpandedStudyInstanceUIDs(updatedExpandedStudyInstanceUIDs); @@ -285,7 +285,6 @@ function _mapDisplaySets( ) { const thumbnailDisplaySets = []; const thumbnailNoImageDisplaySets = []; - displaySets.forEach(ds => { const imageSrc = thumbnailImageSrcMap[ds.displaySetInstanceUID]; const componentType = _getComponentType(ds.Modality); diff --git a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js index c582bd929..eae34e279 100644 --- a/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js +++ b/extensions/measurement-tracking/src/viewports/TrackedCornerstoneViewport.js @@ -10,8 +10,6 @@ import { useViewportGrid, useViewportDialog, } from '@ohif/ui'; -import debounce from 'lodash.debounce'; -import throttle from 'lodash.throttle'; import { useTrackedMeasurements } from './../getContextModule'; // TODO -> Get this list from the list of tracked measurements. @@ -43,7 +41,6 @@ function TrackedCornerstoneViewport({ const [ { activeViewportIndex, viewports }, - dispatchViewportGrid, ] = useViewportGrid(); // viewportIndex, onSubmit const [viewportDialogState, viewportDialogApi] = useViewportDialog(); @@ -209,15 +206,17 @@ function TrackedCornerstoneViewport({ SeriesInstanceUID, SeriesNumber, } = displaySet; + const { PatientID, PatientName, PatientSex, PatientAge, SliceThickness, + PixelSpacing, + ManufacturerModelName } = displaySet.images[0]; - if (trackedSeries.includes(SeriesInstanceUID) !== isTracked) { setIsTracked(!isTracked); } @@ -226,7 +225,6 @@ function TrackedCornerstoneViewport({ <> alert(`Series ${direction}`)} - showPatientInfo={viewportIndex === activeViewportIndex} showNavArrows={viewportIndex === activeViewportIndex} studyData={{ label: _viewportLabels[firstViewportIndexWithMatchingDisplaySetUid], @@ -237,13 +235,13 @@ function TrackedCornerstoneViewport({ seriesDescription: SeriesDescription, modality: Modality, patientInformation: { - patientName: PatientName ? PatientName.Alphabetic || '' : '', + patientName: PatientName ? OHIF.utils.formatPN(PatientName.Alphabetic) : '', patientSex: PatientSex || '', patientAge: PatientAge || '', MRN: PatientID || '', thickness: `${SliceThickness}mm`, - spacing: '', - scanner: '', + spacing: PixelSpacing && PixelSpacing.length ? `${PixelSpacing[0].toFixed(2)}mm x ${PixelSpacing[1].toFixed(2)}mm` : '', + scanner: ManufacturerModelName || '', }, }} /> diff --git a/extensions/vtk/package.json b/extensions/vtk/package.json index 7a9dd7840..5ef625611 100644 --- a/extensions/vtk/package.json +++ b/extensions/vtk/package.json @@ -33,7 +33,7 @@ "@ohif/ui": "^2.0.0", "cornerstone-core": "^2.3.0", "cornerstone-wado-image-loader": "^3.1.2", - "dcmjs": "0.12.2", + "dcmjs": "0.14.0", "dicom-parser": "^1.8.3", "i18next": "^17.0.3", "i18next-browser-languagedetector": "^3.0.1", diff --git a/platform/core/package.json b/platform/core/package.json index 71dc6b6d7..a14313b13 100644 --- a/platform/core/package.json +++ b/platform/core/package.json @@ -38,7 +38,7 @@ "dependencies": { "@babel/runtime": "7.7.6", "ajv": "^6.10.0", - "dcmjs": "^0.12.4", + "dcmjs": "0.14.0", "dicomweb-client": "^0.6.0", "immer": "6.0.2", "isomorphic-base64": "^1.0.2", diff --git a/platform/core/src/DICOMSR/dataExchange.js b/platform/core/src/DICOMSR/dataExchange.js index aab594da3..2b3cfe172 100644 --- a/platform/core/src/DICOMSR/dataExchange.js +++ b/platform/core/src/DICOMSR/dataExchange.js @@ -6,6 +6,10 @@ import { stowSRFromMeasurements, } from './handleStructuredReport'; import findMostRecentStructuredReport from './utils/findMostRecentStructuredReport'; +import cornerstoneTools from 'cornerstone-tools'; +import dcmjs from 'dcmjs'; + +const { MeasurementReport } = dcmjs.adapters.Cornerstone; /** * @@ -47,7 +51,7 @@ const retrieveMeasurements = server => { * @param {serverType} server * @returns {Object} With message to be displayed on success */ -const storeMeasurements = async (measurementData, filter, server) => { +const storeMeasurementsOld = async (measurementData, filter, server) => { log.info('[DICOMSR] storeMeasurements'); if (!server || server.type !== 'dicomWeb') { @@ -78,4 +82,128 @@ const storeMeasurements = async (measurementData, filter, server) => { } }; -export { retrieveMeasurements, storeMeasurements }; +/** + * + * @param {object[]} measurementData An array of measurements from the measurements service + * that you wish to serialize. + */ +const downloadReport = measurementData => { + const srDataset = generateReport(measurementData); + const reportBlob = dcmjs.data.datasetToBlob(srDataset); + + //Create a URL for the binary. + var objectUrl = URL.createObjectURL(reportBlob); + window.location.assign(objectUrl); +}; + +/** + * + * @param {object[]} measurementData An array of measurements from the measurements service + * that you wish to serialize. + */ +const generateReport = measurementData => { + const ids = measurementData.map(md => md.id); + const filteredToolState = _getFilteredCornerstoneToolState(ids); + + const report = MeasurementReport.generateReport( + filteredToolState, + cornerstone.metaData + ); + + return report.dataset; +}; + +/** + * + * @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. + */ +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'); + + if (!dataSource || !dataSource.store || !dataSource.store.dicom) { + log.error('[DICOMSR] datasource has no dataSource.store.dicom endpoint!'); + return Promise.reject({}); + } + + const naturalizedReport = generateReport(measurementData); + const { StudyInstanceUID } = naturalizedReport; + + try { + await dataSource.store.dicom(naturalizedReport); + + if (StudyInstanceUID) { + studies.deleteStudyMetadataPromise(StudyInstanceUID); + } + + return { + message: 'Measurements saved successfully', + }; + } catch (error) { + log.error( + `[DICOMSR] Error while saving the measurements: ${error.message}` + ); + throw new Error('Error while saving the measurements.'); + } +}; + +function _getFilteredCornerstoneToolState(uidFilter) { + const globalToolState = cornerstoneTools.globalImageIdSpecificToolStateManager.saveToolState(); + const filteredToolState = {}; + + function addToFilteredToolState(imageId, toolType, toolDataI) { + if (!filteredToolState[imageId]) { + filteredToolState[imageId] = {}; + } + + const imageIdSpecificToolState = filteredToolState[imageId]; + + if (!imageIdSpecificToolState[toolType]) { + imageIdSpecificToolState[toolType] = { + data: [], + }; + } + + const toolData = imageIdSpecificToolState[toolType].data; + + toolData.push(toolDataI); + } + + const uids = uidFilter.slice(); + const imageIds = Object.keys(globalToolState); + + for (let i = 0; i < imageIds.length; i++) { + const imageId = imageIds[i]; + const imageIdSpecificToolState = globalToolState[imageId]; + + const toolTypes = Object.keys(imageIdSpecificToolState); + + for (let j = 0; j < toolTypes.length; j++) { + const toolType = toolTypes[j]; + const toolData = imageIdSpecificToolState[toolType].data; + + if (toolData) { + for (let k = 0; k < toolData.length; k++) { + const toolDataK = toolData[k]; + const uidIndex = uids.findIndex(uid => uid === toolDataK.id); + + if (uidIndex !== -1) { + addToFilteredToolState(imageId, toolType, toolDataK); + uids.splice(uidIndex, 1); + + if (!uids.length) { + return filteredToolState; + } + } + } + } + } + } + + return filteredToolState; +} + +export { retrieveMeasurements, storeMeasurements, downloadReport }; diff --git a/platform/core/src/DICOMSR/index.js b/platform/core/src/DICOMSR/index.js index 786202642..759f26cb1 100644 --- a/platform/core/src/DICOMSR/index.js +++ b/platform/core/src/DICOMSR/index.js @@ -1,9 +1,14 @@ -import { retrieveMeasurements, storeMeasurements } from './dataExchange'; +import { + retrieveMeasurements, + storeMeasurements, + downloadReport, +} from './dataExchange'; import isToolSupported from './utils/isToolSupported'; const DICOMSR = { retrieveMeasurements, storeMeasurements, + downloadReport, isToolSupported, }; diff --git a/platform/core/src/DataSources/IWebApiDataSource.js b/platform/core/src/DataSources/IWebApiDataSource.js index e10914e9e..10285d364 100644 --- a/platform/core/src/DataSources/IWebApiDataSource.js +++ b/platform/core/src/DataSources/IWebApiDataSource.js @@ -15,6 +15,7 @@ import { DicomMetadataStore } from '@ohif/core'; function create({ query, retrieve, + store, retrieveSeriesMetadata, getImageIdsForDisplaySet, }) { @@ -44,13 +45,20 @@ function create({ series: {}, }; + const defaultStore = { + dicom: async naturalizedDataset => { + throw new Error( + 'store.dicom(naturalizedDicom, StudyInstanceUID) not implemented for dataSource.' + ); + }, + }; + return { query: query || defaultQuery, retrieve: retrieve || defaultRetrieve, + store: store || defaultStore, getImageIdsForDisplaySet, retrieveSeriesMetadata, - // then go get all series level metadata. - // Store this in the DICOM MetadataStore. }; } diff --git a/platform/core/src/extensions/ExtensionManager.js b/platform/core/src/extensions/ExtensionManager.js index 14dfde227..0ce25d6b6 100644 --- a/platform/core/src/extensions/ExtensionManager.js +++ b/platform/core/src/extensions/ExtensionManager.js @@ -137,6 +137,7 @@ export default class ExtensionManager { getDataSources = dataSourceName => { if (dataSourceName === undefined) { + // Default to the activeDataSource dataSourceName = this.activeDataSource; } @@ -144,6 +145,10 @@ export default class ExtensionManager { return this.dataSourceMap[dataSourceName]; }; + getActiveDataSource = () => { + return this.activeDataSource; + }; + /** * @private * @param {string} moduleType diff --git a/platform/core/src/services/UIModalService/index.js b/platform/core/src/services/UIModalService/index.js index ed2eab96a..884e53500 100644 --- a/platform/core/src/services/UIModalService/index.js +++ b/platform/core/src/services/UIModalService/index.js @@ -33,7 +33,7 @@ const serviceImplementation = { function _show({ content = null, contentProps = null, - shouldCloseOnEsc = false, + shouldCloseOnEsc = true, isOpen = true, closeButton = true, title = null, diff --git a/platform/core/src/utils/formatPN.js b/platform/core/src/utils/formatPN.js new file mode 100644 index 000000000..fb019ab5e --- /dev/null +++ b/platform/core/src/utils/formatPN.js @@ -0,0 +1,18 @@ +/** + * Formats a patient name for display purposes + */ +export default function formatPN(name) { + if (!name) { + return; + } + + // Convert the first ^ to a ', '. String.replace() only affects + // the first appearance of the character. + const commaBetweenFirstAndLast = name.replace('^', ', '); + + // Replace any remaining '^' characters with spaces + const cleaned = commaBetweenFirstAndLast.replace(/\^/g, ' '); + + // Trim any extraneous whitespace + return cleaned.trim(); +} diff --git a/platform/core/src/utils/index.js b/platform/core/src/utils/index.js index 6e9ea3140..a69889262 100644 --- a/platform/core/src/utils/index.js +++ b/platform/core/src/utils/index.js @@ -15,6 +15,7 @@ import makeCancelable from './makeCancelable'; import hotkeys from './hotkeys'; import Queue from './Queue'; import isDicomUid from './isDicomUid'; +import formatPN from './formatPN'; import resolveObjectPath from './resolveObjectPath'; import * as hierarchicalListUtils from './hierarchicalListUtils'; import * as progressTrackingUtils from './progressTrackingUtils'; @@ -26,6 +27,7 @@ const utils = { addServers, sortBy, writeScript, + formatPN, b64toBlob, StackManager, studyMetadataManager, diff --git a/platform/ui/index.js b/platform/ui/index.js index 66f9fad21..ad2d13ec5 100644 --- a/platform/ui/index.js +++ b/platform/ui/index.js @@ -76,6 +76,7 @@ export { Typography, Viewport, ViewportActionBar, + ViewportDownloadForm, ViewportGrid, ViewportPane, } from './src/components'; diff --git a/platform/ui/src/assets/icons/close.svg b/platform/ui/src/assets/icons/close.svg new file mode 100644 index 000000000..83b259fca --- /dev/null +++ b/platform/ui/src/assets/icons/close.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/platform/ui/src/assets/icons/link.svg b/platform/ui/src/assets/icons/link.svg new file mode 100644 index 000000000..7c99fc27c --- /dev/null +++ b/platform/ui/src/assets/icons/link.svg @@ -0,0 +1,11 @@ + + + + diff --git a/platform/ui/src/assets/icons/unlink.svg b/platform/ui/src/assets/icons/unlink.svg new file mode 100644 index 000000000..37c53bbb9 --- /dev/null +++ b/platform/ui/src/assets/icons/unlink.svg @@ -0,0 +1,11 @@ + + Unlink + + diff --git a/platform/ui/src/components/EmptyStudies/EmptyStudies.js b/platform/ui/src/components/EmptyStudies/EmptyStudies.js index 48e3baaa6..893ecf3e3 100644 --- a/platform/ui/src/components/EmptyStudies/EmptyStudies.js +++ b/platform/ui/src/components/EmptyStudies/EmptyStudies.js @@ -3,12 +3,13 @@ import PropTypes from 'prop-types'; import classnames from 'classnames'; import { Icon, Typography } from '@ohif/ui'; -const EmptyStudies = ({ className }) => { +// TODO: Add loading spinner to OHIF + use it here. +const EmptyStudies = ({ className, isLoading }) => { return (
- No studies available + {!isLoading ? 'No studies available' : 'Loading...'}
); diff --git a/platform/ui/src/components/Icon/getIcon.jsx b/platform/ui/src/components/Icon/getIcon.jsx index 145539060..8f0a7a534 100644 --- a/platform/ui/src/components/Icon/getIcon.jsx +++ b/platform/ui/src/components/Icon/getIcon.jsx @@ -4,6 +4,7 @@ import React from 'react'; import arrowDown from './../../assets/icons/arrow-down.svg'; import calendar from './../../assets/icons/calendar.svg'; import cancel from './../../assets/icons/cancel.svg'; +import close from './../../assets/icons/close.svg'; import dottedCircle from './../../assets/icons/dotted-circle.svg'; import circledCheckmark from './../../assets/icons/circled-checkmark.svg'; import chevronDown from './../../assets/icons/chevron-down.svg'; @@ -16,6 +17,7 @@ import info from './../../assets/icons/info.svg'; import infoLink from './../../assets/icons/info-link.svg'; import launchArrow from './../../assets/icons/launch-arrow.svg'; import launchInfo from './../../assets/icons/launch-info.svg'; +import link from './../../assets/icons/link.svg'; import listBullets from './../../assets/icons/list-bullets.svg'; import lock from './../../assets/icons/lock.svg'; import logoOhifSmall from './../../assets/icons/logo-ohif-small.svg'; @@ -30,6 +32,7 @@ import sorting from './../../assets/icons/sorting.svg'; import sortingActiveDown from './../../assets/icons/sorting-active-down.svg'; import sortingActiveUp from './../../assets/icons/sorting-active-up.svg'; import tracked from './../../assets/icons/tracked.svg'; +import unlink from './../../assets/icons/unlink.svg'; /** Tools */ import toolZoom from './../../assets/icons/tool-zoom.svg'; @@ -47,6 +50,7 @@ const ICONS = { 'arrow-down': arrowDown, calendar: calendar, cancel: cancel, + close: close, 'dotted-circle': dottedCircle, 'circled-checkmark': circledCheckmark, 'chevron-down': chevronDown, @@ -59,6 +63,7 @@ const ICONS = { 'info-link': infoLink, 'launch-arrow': launchArrow, 'launch-info': launchInfo, + link: link, 'list-bullets': listBullets, lock: lock, 'logo-ohif-small': logoOhifSmall, @@ -73,6 +78,7 @@ const ICONS = { 'sorting-active-up': sortingActiveUp, sorting: sorting, tracked: tracked, + unlink: unlink, /** Tools */ 'tool-zoom': toolZoom, diff --git a/platform/ui/src/components/IconButton/IconButton.jsx b/platform/ui/src/components/IconButton/IconButton.jsx index d92a6b9ca..290bfa273 100644 --- a/platform/ui/src/components/IconButton/IconButton.jsx +++ b/platform/ui/src/components/IconButton/IconButton.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import classnames from 'classnames'; const baseClasses = - 'text-center items-center justify-center outline-none font-bold focus:outline-none'; + 'text-center items-center justify-center transition duration-300 ease-in-out outline-none font-bold focus:outline-none'; const roundedClasses = { none: '', @@ -84,7 +84,7 @@ const IconButton = ({ }) => { const buttonElement = useRef(null); - const handleOnClick = (e) => { + const handleOnClick = e => { buttonElement.current.blur(); onClick(e); }; @@ -113,7 +113,7 @@ const IconButton = ({ }; IconButton.defaultProps = { - onClick: () => { }, + onClick: () => {}, color: 'default', disabled: false, fullWidth: false, diff --git a/platform/ui/src/components/Input/Input.jsx b/platform/ui/src/components/Input/Input.jsx index 51b40f0e5..8f26ad4e7 100644 --- a/platform/ui/src/components/Input/Input.jsx +++ b/platform/ui/src/components/Input/Input.jsx @@ -4,11 +4,11 @@ import Label from '../Label'; import classnames from 'classnames'; const baseInputClasses = - 'shadow transition duration-300 appearance-none border rounded w-full py-2 px-3 text-sm text-white hover:border-gray-500 leading-tight focus:border-gray-500 focus:outline-none'; + 'shadow transition duration-300 appearance-none border border-primary-main hover:border-gray-500 focus:border-gray-500 focus:outline-none rounded w-full py-2 px-3 mt-2 text-sm text-white leading-tight focus:outline-none'; const transparentClasses = { true: 'bg-transparent', - false: '', + false: 'bg-black', }; const Input = ({ @@ -16,7 +16,7 @@ const Input = ({ containerClassName = '', labelClassName = '', className = '', - transparent = true, + transparent = false, type = 'text', value, onChange, diff --git a/platform/ui/src/components/InputText/InputText.jsx b/platform/ui/src/components/InputText/InputText.jsx index 2a244d7e8..2f6fbe70b 100644 --- a/platform/ui/src/components/InputText/InputText.jsx +++ b/platform/ui/src/components/InputText/InputText.jsx @@ -23,7 +23,7 @@ const InputText = ({ type="text" containerClassName="mr-2" value={value} - onChange={(event) => { + onChange={event => { onChange(event.target.value); }} /> @@ -33,15 +33,17 @@ const InputText = ({ InputText.defaultProps = { value: '', + isSortable: false, + onLabelClick: () => {}, + sortDirection: 'none', }; InputText.propTypes = { label: PropTypes.string.isRequired, - isSortable: PropTypes.bool.isRequired, - sortDirection: PropTypes.oneOf(['ascending', 'descending', 'none']) - .isRequired, - onLabelClick: PropTypes.func.isRequired, - value: PropTypes.string, + isSortable: PropTypes.bool, + sortDirection: PropTypes.oneOf(['ascending', 'descending', 'none']), + onLabelClick: PropTypes.func, + value: PropTypes.any, onChange: PropTypes.func.isRequired, }; diff --git a/platform/ui/src/components/Modal/Modal.css b/platform/ui/src/components/Modal/Modal.css new file mode 100644 index 000000000..f67d31616 --- /dev/null +++ b/platform/ui/src/components/Modal/Modal.css @@ -0,0 +1,3 @@ +.modal-content { + max-height: calc(100vh - theme('spacing.250px')); +} diff --git a/platform/ui/src/components/Modal/Modal.jsx b/platform/ui/src/components/Modal/Modal.jsx index 4ef669cbf..3a245f8aa 100644 --- a/platform/ui/src/components/Modal/Modal.jsx +++ b/platform/ui/src/components/Modal/Modal.jsx @@ -1,22 +1,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import ReactModal from 'react-modal'; -import classNames from 'classnames'; -const customStyle = { - overlay: { - zIndex: 1071, - backgroundColor: 'rgb(0, 0, 0, 0.5)', - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - }, -}; +import './Modal.css'; + +import { Typography, useModal, IconButton, Icon } from '@ohif/ui'; ReactModal.setAppElement(document.getElementById('root')); const Modal = ({ - className, closeButton, shouldCloseOnEsc, isOpen, @@ -24,15 +16,27 @@ const Modal = ({ onClose, children, }) => { + const { hide } = useModal(); + + const handleClose = () => { + hide(); + }; + const renderHeader = () => { return ( title && ( -
-

{title}

+
+ {title} {closeButton && ( - + + + )}
) @@ -41,22 +45,26 @@ const Modal = ({ return ( - <> - {renderHeader()} -
{children}
- +
{renderHeader()}
+
+ {children} +
); }; +Modal.defaultProps = { + shouldCloseOnEsc: true, +}; + Modal.propTypes = { - className: PropTypes.string, closeButton: PropTypes.bool, shouldCloseOnEsc: PropTypes.bool, isOpen: PropTypes.bool, diff --git a/platform/ui/src/components/Select/Select.jsx b/platform/ui/src/components/Select/Select.jsx index 399edd541..0cc410a46 100644 --- a/platform/ui/src/components/Select/Select.jsx +++ b/platform/ui/src/components/Select/Select.jsx @@ -76,10 +76,9 @@ const Select = ({ options={options} value={selectedOptions} onChange={(selectedOptions, { action }) => { - const newSelection = !selectedOptions.length ? selectedOptions : selectedOptions.reduce( - (acc, curr) => acc.concat([curr.value]), - [] - ); + const newSelection = !selectedOptions.length + ? selectedOptions + : selectedOptions.reduce((acc, curr) => acc.concat([curr.value]), []); onChange(newSelection, action); }} > diff --git a/platform/ui/src/components/StudyListFilter/StudyListFilter.jsx b/platform/ui/src/components/StudyListFilter/StudyListFilter.jsx index 2d409a555..0d06bb2e3 100644 --- a/platform/ui/src/components/StudyListFilter/StudyListFilter.jsx +++ b/platform/ui/src/components/StudyListFilter/StudyListFilter.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { Button, Icon, Typography, InputGroup } from '@ohif/ui'; +import { Button, Icon, Typography, InputGroup, useModal } from '@ohif/ui'; const StudyListFilter = ({ filtersMeta, @@ -20,6 +20,16 @@ const StudyListFilter = ({ }); }; const isSortingEnable = numOfStudies > 0 && numOfStudies <= 100; + const { show } = useModal(); + + const showLearnMoreContent = () => { + const modalContent = () =>
Search Instructions
; + + show({ + content: modalContent, + title: 'Learn More', + }); + }; return ( @@ -38,6 +48,7 @@ const StudyListFilter = ({ color="inherit" className="text-primary-active" startIcon={} + onClick={showLearnMoreContent} > Learn more diff --git a/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx b/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx index 9133b522c..9129d871c 100644 --- a/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx +++ b/platform/ui/src/components/ViewportActionBar/ViewportActionBar.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; import classnames from 'classnames'; import { Icon, ButtonGroup, Button, Tooltip } from '@ohif/ui'; @@ -13,9 +13,11 @@ const classes = { const ViewportActionBar = ({ studyData, showNavArrows, - showPatientInfo, + showPatientInfo: patientInfoVisibility, onSeriesChange, }) => { + const [showPatientInfo, setShowPatientInfo] = useState(patientInfoVisibility); + const { label, isTracked, @@ -37,6 +39,8 @@ const ViewportActionBar = ({ scanner, } = patientInformation; + const onPatientInfoClick = () => setShowPatientInfo(!showPatientInfo) + const renderIconStatus = () => { if (modality === 'SR') { return ( @@ -60,29 +64,30 @@ const ViewportActionBar = ({ {!isTracked ? ( ) : ( - -
- -
-
- - Series is + +
+ +
+
+ + Series is tracked and can be viewed
in the measurement panel
+
- - } - > - -
- )} + } + > + + + )} ); }; + return (
@@ -131,19 +136,18 @@ const ViewportActionBar = ({
)} - {showPatientInfo && ( -
- -
- )} +
+ +
); }; @@ -174,7 +178,7 @@ ViewportActionBar.propTypes = { ViewportActionBar.defaultProps = { showNavArrows: true, - showPatientInfo: true, + showPatientInfo: false, }; function PatientInfo({ @@ -185,11 +189,14 @@ function PatientInfo({ thickness, spacing, scanner, + isOpen, }) { return (
@@ -236,7 +243,7 @@ function PatientInfo({
- } + )} >
diff --git a/platform/ui/src/components/ViewportDownloadForm/ViewportDownloadForm.jsx b/platform/ui/src/components/ViewportDownloadForm/ViewportDownloadForm.jsx new file mode 100644 index 000000000..06c682502 --- /dev/null +++ b/platform/ui/src/components/ViewportDownloadForm/ViewportDownloadForm.jsx @@ -0,0 +1,432 @@ +import React, { + useCallback, + useEffect, + useState, + createRef, + useRef, +} from 'react'; + +import classnames from 'classnames'; + +import { + Typography, + Input, + Tooltip, + IconButton, + Icon, + Select, + InputLabelWrapper, + Button, +} from '@ohif/ui'; + +const FILE_TYPE_OPTIONS = [ + { + value: 'jpg', + label: 'jpg', + }, + { + value: 'png', + label: 'png', + }, +]; + +const DEFAULT_FILENAME = 'image'; +const REFRESH_VIEWPORT_TIMEOUT = 1000; + +const ViewportDownloadForm = ({ + activeViewport, + onClose, + updateViewportPreview, + enableViewport, + disableViewport, + toggleAnnotations, + loadImage, + downloadBlob, + defaultSize, + minimumSize, + maximumSize, + canvasClass, +}) => { + const [filename, setFilename] = useState(DEFAULT_FILENAME); + const [fileType, setFileType] = useState(['jpg']); + + const [dimensions, setDimensions] = useState({ + width: defaultSize, + height: defaultSize, + }); + + const [showAnnotations, setShowAnnotations] = useState(true); + + const [keepAspect, setKeepAspect] = useState(true); + const [aspectMultiplier, setAspectMultiplier] = useState({ + width: 1, + height: 1, + }); + + const [viewportElement, setViewportElement] = useState(); + const [viewportElementDimensions, setViewportElementDimensions] = useState({ + width: defaultSize, + height: defaultSize, + }); + + const [downloadCanvas, setDownloadCanvas] = useState({ + ref: createRef(), + width: defaultSize, + height: defaultSize, + }); + + const [viewportPreview, setViewportPreview] = useState({ + src: null, + width: defaultSize, + height: defaultSize, + }); + + const [error, setError] = useState({ + width: false, + height: false, + filename: false, + }); + + const hasError = Object.values(error).includes(true); + + const refreshViewport = useRef(null); + + const onKeepAspectToggle = () => { + const { width, height } = dimensions; + const aspectMultiplier = { ...aspectMultiplier }; + if (!keepAspect) { + const base = Math.min(width, height); + aspectMultiplier.width = width / base; + aspectMultiplier.height = height / base; + setAspectMultiplier(aspectMultiplier); + } + + setKeepAspect(!keepAspect); + }; + + const downloadImage = () => { + downloadBlob( + filename || DEFAULT_FILENAME, + fileType, + viewportElement, + downloadCanvas.ref.current + ); + }; + + /** + * @param {object} value - Input value + * @param {string} dimension - "height" | "width" + */ + const onDimensionsChange = (value, dimension) => { + const oppositeDimension = dimension === 'height' ? 'width' : 'height'; + const sanitizedTargetValue = value.replace(/\D/, ''); + const isEmpty = sanitizedTargetValue === ''; + const newDimensions = { ...dimensions }; + const updatedDimension = isEmpty + ? '' + : Math.min(sanitizedTargetValue, maximumSize); + + if (updatedDimension === dimensions[dimension]) { + return; + } + + newDimensions[dimension] = updatedDimension; + + if (keepAspect && newDimensions[oppositeDimension] !== '') { + newDimensions[oppositeDimension] = Math.round( + newDimensions[dimension] * aspectMultiplier[oppositeDimension] + ); + } + + // In current code, keepAspect is always `true` + // And we always start w/ a square width/height + setDimensions(newDimensions); + + // Only update if value is non-empty + if (!isEmpty) { + setViewportElementDimensions(newDimensions); + setDownloadCanvas(state => ({ + ...state, + ...newDimensions, + })); + } + }; + + const error_messages = { + width: 'The minimum valid width is 100px.', + height: 'The minimum valid height is 100px.', + filename: 'The file name cannot be empty.', + }; + + const renderErrorHandler = errorType => { + if (!error[errorType]) { + return null; + } + + return ( + + {error_messages[errorType]} + + ); + }; + + const validSize = useCallback( + value => (value >= minimumSize ? value : minimumSize), + [minimumSize] + ); + + const loadAndUpdateViewports = useCallback(async () => { + const { width: scaledWidth, height: scaledHeight } = await loadImage( + activeViewport, + viewportElement, + dimensions.width, + dimensions.height + ); + + toggleAnnotations(showAnnotations, viewportElement); + + const scaledDimensions = { + height: validSize(scaledHeight), + width: validSize(scaledWidth), + }; + + setViewportElementDimensions(scaledDimensions); + setDownloadCanvas(state => ({ + ...state, + ...scaledDimensions, + })); + + const { + dataUrl, + width: viewportElementWidth, + height: viewportElementHeight, + } = await updateViewportPreview( + viewportElement, + downloadCanvas.ref.current, + fileType + ); + + setViewportPreview(state => ({ + ...state, + src: dataUrl, + width: validSize(viewportElementWidth), + height: validSize(viewportElementHeight), + })); + }, [ + loadImage, + activeViewport, + viewportElement, + dimensions.width, + dimensions.height, + toggleAnnotations, + showAnnotations, + validSize, + updateViewportPreview, + downloadCanvas.ref, + fileType, + ]); + + useEffect(() => { + enableViewport(viewportElement); + + return () => { + disableViewport(viewportElement); + }; + }, [disableViewport, enableViewport, viewportElement]); + + useEffect(() => { + if (refreshViewport.current !== null) { + clearTimeout(refreshViewport.current); + } + + refreshViewport.current = setTimeout(() => { + refreshViewport.current = null; + loadAndUpdateViewports(); + }, REFRESH_VIEWPORT_TIMEOUT); + }, [ + activeViewport, + viewportElement, + showAnnotations, + dimensions, + loadImage, + toggleAnnotations, + updateViewportPreview, + fileType, + downloadCanvas.ref, + minimumSize, + maximumSize, + loadAndUpdateViewports, + ]); + + useEffect(() => { + const { width, height } = dimensions; + const hasError = { + width: width < minimumSize, + height: height < minimumSize, + filename: !filename, + }; + + setError({ ...hasError }); + }, [dimensions, filename, minimumSize]); + + return ( +
+ + Please specify the dimensions, filename, and desired type for the output + image. + + +
+
+ setFilename(value)} + label="File Name" + /> + {renderErrorHandler('filename')} +
+
+
+
+
+ onDimensionsChange(value, 'width')} + data-cy="image-width" + /> + {renderErrorHandler('width')} +
+
+ onDimensionsChange(value, 'height')} + data-cy="image-height" + /> + {renderErrorHandler('height')} +
+
+ +
+ + + + + +
+
+ +
+
+ {}} + > + setShowAnnotations(event.target.checked)} + /> + Show Annotations + +
+
+
+
+ +
+
setViewportElement(ref)} + > + +
+ + {viewportPreview.src ? ( +
+ Image preview + Preview +
+ ) : ( +
+ Loading Image Preview... +
+ )} +
+ +
+ + +
+
+ ); +}; + +export default ViewportDownloadForm; diff --git a/platform/ui/src/components/ViewportDownloadForm/index.js b/platform/ui/src/components/ViewportDownloadForm/index.js new file mode 100644 index 000000000..d630f3a19 --- /dev/null +++ b/platform/ui/src/components/ViewportDownloadForm/index.js @@ -0,0 +1 @@ +export { default } from './ViewportDownloadForm'; diff --git a/platform/ui/src/components/index.js b/platform/ui/src/components/index.js index 76d4f2729..50e96bef2 100644 --- a/platform/ui/src/components/index.js +++ b/platform/ui/src/components/index.js @@ -46,6 +46,7 @@ import Tooltip from './Tooltip'; import Typography from './Typography'; import Viewport from './Viewport'; import ViewportActionBar from './ViewportActionBar'; +import ViewportDownloadForm from './ViewportDownloadForm'; import ViewportGrid from './ViewportGrid'; import ViewportPane from './ViewportPane'; @@ -99,6 +100,7 @@ export { Typography, Viewport, ViewportActionBar, + ViewportDownloadForm, ViewportGrid, ViewportPane, }; diff --git a/platform/ui/src/contextProviders/ModalComponent.jsx b/platform/ui/src/contextProviders/ModalComponent.jsx index b3b6602a7..cf7965812 100644 --- a/platform/ui/src/contextProviders/ModalComponent.jsx +++ b/platform/ui/src/contextProviders/ModalComponent.jsx @@ -16,7 +16,7 @@ const ModalComponent = ({ ModalComponent.defaultProps = { content: null, contentProps: null, - shouldCloseOnEsc: false, + shouldCloseOnEsc: true, isOpen: true, closeButton: true, title: null, diff --git a/platform/ui/src/contextProviders/ModalProvider.jsx b/platform/ui/src/contextProviders/ModalProvider.jsx index e8d65635d..ff9224bca 100644 --- a/platform/ui/src/contextProviders/ModalProvider.jsx +++ b/platform/ui/src/contextProviders/ModalProvider.jsx @@ -19,7 +19,7 @@ export const useModal = () => useContext(ModalContext); * @typedef {Object} ModalProps * @property {ReactElement|HTMLElement} [content=null] Modal content. * @property {Object} [contentProps=null] Modal content props. - * @property {boolean} [shouldCloseOnEsc=false] Modal is dismissible via the esc key. + * @property {boolean} [shouldCloseOnEsc=true] Modal is dismissible via the esc key. * @property {boolean} [isOpen=true] Make the Modal visible or hidden. * @property {boolean} [closeButton=true] Should the modal body render the close button. * @property {string} [title=null] Should the modal render the title independently of the body content. @@ -30,7 +30,7 @@ const ModalProvider = ({ children, modal: Modal, service }) => { const DEFAULT_OPTIONS = { content: null, contentProps: null, - shouldCloseOnEsc: false, + shouldCloseOnEsc: true, isOpen: true, closeButton: true, title: null, diff --git a/platform/ui/tailwind.config.js b/platform/ui/tailwind.config.js index 5cdbcdf50..3e32e4955 100644 --- a/platform/ui/tailwind.config.js +++ b/platform/ui/tailwind.config.js @@ -10,6 +10,7 @@ module.exports = { xl: '1280px', }, colors: { + overlay: 'rgba(0, 0, 0, 0.8)', transparent: 'transparent', black: '#000', white: '#fff', @@ -17,10 +18,10 @@ module.exports = { inherit: 'inherit', indigo: { - dark: '#0b1a42' + dark: '#0b1a42', }, aqua: { - pale: '#7bb2ce' + pale: '#7bb2ce', }, primary: { diff --git a/platform/viewer/package.json b/platform/viewer/package.json index 5a88f296b..9364101ef 100644 --- a/platform/viewer/package.json +++ b/platform/viewer/package.json @@ -69,7 +69,7 @@ "cornerstone-math": "^0.1.8", "cornerstone-tools": "4.16.0", "cornerstone-wado-image-loader": "^3.1.2", - "dcmjs": "^0.12.2", + "dcmjs": "0.14.0", "dicom-parser": "^1.8.3", "dicomweb-client": "^0.4.4", "dotenv-webpack": "^1.7.0", diff --git a/platform/viewer/src/components/ViewportGrid.jsx b/platform/viewer/src/components/ViewportGrid.jsx index fa7a6c7e4..2f99f7b48 100644 --- a/platform/viewer/src/components/ViewportGrid.jsx +++ b/platform/viewer/src/components/ViewportGrid.jsx @@ -1,10 +1,12 @@ /** * CSS Grid Reference: http://grid.malven.co/ */ -import React from 'react'; +import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; import { ViewportGrid, ViewportPane, useViewportGrid } from '@ohif/ui'; import EmptyViewport from './EmptyViewport'; +import { classes } from '@ohif/core'; +const { ImageSet } = classes; function ViewerViewportGrid(props) { const { servicesManager, viewportComponents, dataSource } = props; @@ -20,6 +22,29 @@ function ViewerViewportGrid(props) { // TODO -> Need some way of selecting which displaySets hit the viewports. const { DisplaySetService } = servicesManager.services; + useEffect(() => { + const { unsubscribe } = DisplaySetService.subscribe( + DisplaySetService.EVENTS.DISPLAY_SETS_CHANGED, + displaySets => { + displaySets.sort((a, b) => { + const isImageSet = x => x instanceof ImageSet; + return (isImageSet(a) === isImageSet(b)) ? 0 : isImageSet(a) ? -1 : 1; + }); + dispatch({ + type: 'SET_DISPLAYSET_FOR_VIEWPORT', + payload: { + viewportIndex: 0, + displaySetInstanceUID: displaySets[0].displaySetInstanceUID, + }, + }); + }, + ); + + return () => { + unsubscribe(); + }; + }, []); + // TODO -> Make a HangingProtocolService const HangingProtocolService = displaySets => { let displaySetInstanceUID; diff --git a/platform/viewer/src/routes/DataSourceWrapper.jsx b/platform/viewer/src/routes/DataSourceWrapper.jsx index d0a0711e3..158594128 100644 --- a/platform/viewer/src/routes/DataSourceWrapper.jsx +++ b/platform/viewer/src/routes/DataSourceWrapper.jsx @@ -46,14 +46,17 @@ function DataSourceWrapper(props) { // studies.processResults --> // But only for LayoutTemplate type of 'list'? // Or no data fetching here, and just hand down my source - const [data, setData] = useState(); + const [data, setData] = useState([]); + const [isLoading, setIsLoading] = useState(false); useEffect(() => { // 204: no content async function getData() { + setIsLoading(true); const searchResults = await dataSource.query.studies.search( queryFilterValues ); setData(searchResults); + setIsLoading(false); } try { @@ -61,23 +64,19 @@ function DataSourceWrapper(props) { } catch (ex) { console.warn(ex); } - console.log('DataSourceWrapper: useEffect'); // eslint-disable-next-line react-hooks/exhaustive-deps }, [history.location.search]); // queryFilterValues // TODO: Better way to pass DataSource? return ( - - {data && ( - - )} - + ); } diff --git a/platform/viewer/src/routes/WorkList/WorkList.jsx b/platform/viewer/src/routes/WorkList/WorkList.jsx index 264bc87e2..e3610cf90 100644 --- a/platform/viewer/src/routes/WorkList/WorkList.jsx +++ b/platform/viewer/src/routes/WorkList/WorkList.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { Suspense, useState, useEffect } from 'react'; import classnames from 'classnames'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; @@ -28,15 +28,13 @@ const seriesInStudiesMap = new Map(); * TODO: * - debounce `setFilterValues` (150ms?) */ -function WorkList({ history, data: studies, dataSource }) { +function WorkList({ history, data: studies, isLoadingData, dataSource }) { // ~ Modes const [appConfig] = useAppConfig(); // ~ Filters const query = useQuery(); const queryFilterValues = _getQueryFilterValues(query); - const [filterValues, _setFilterValues] = useState( - Object.assign({}, defaultFilterValues, queryFilterValues) - ); + const [filterValues, _setFilterValues] = useState({ ...defaultFilterValues, ...queryFilterValues }); const debouncedFilterValues = useDebounce(filterValues, 200); const { resultsPerPage, pageNumber, sortBy, sortDirection } = filterValues; @@ -80,6 +78,7 @@ function WorkList({ history, data: studies, dataSource }) { return 0; }); + // ~ Rows & Studies const [expandedRows, setExpandedRows] = useState([]); const [studiesWithSeriesData, setStudiesWithSeriesData] = useState([]); @@ -190,6 +189,7 @@ function WorkList({ history, data: studies, dataSource }) { return filterValues[name] !== defaultFilterValues[name]; }); }; + const tableDataSource = sortedStudies.map((study, key) => { const rowKey = key + 1; const isExpanded = expandedRows.some(k => k === rowKey); @@ -211,8 +211,8 @@ function WorkList({ history, data: studies, dataSource }) { content: patientName ? ( patientName ) : ( - (Empty) - ), + (Empty) + ), title: patientName, gridCol: 4, }, @@ -299,13 +299,13 @@ function WorkList({ history, data: studies, dataSource }) { seriesTableDataSource={ seriesInStudiesMap.has(studyInstanceUid) ? seriesInStudiesMap.get(studyInstanceUid).map(s => { - return { - description: s.description || '(empty)', - seriesNumber: s.seriesNumber || '', - modality: s.modality || '', - instances: s.numSeriesInstances || '', - }; - }) + return { + description: s.description || '(empty)', + seriesNumber: s.seriesNumber || '', + modality: s.modality || '', + instances: s.numSeriesInstances || '', + }; + }) : [] } > @@ -322,7 +322,7 @@ function WorkList({ history, data: studies, dataSource }) {
); } diff --git a/yarn.lock b/yarn.lock index 6b7ba067c..7cb2da6e7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7353,10 +7353,10 @@ dateformat@^3.0.0: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== -dcmjs@^0.12.2, dcmjs@^0.12.4: - version "0.12.4" - resolved "https://registry.yarnpkg.com/dcmjs/-/dcmjs-0.12.4.tgz#82c24abdc357ea5281b78eb2cae8b781f7392aa3" - integrity sha512-N1ZsXqZIysirqdytb7h572TyIjmxpvCjrzdjtQsuPN8gC2EpxsUHQ598CPzaJBpBy9i1kfKuq4h2Jwt99cr/QQ== +dcmjs@0.14.0: + version "0.14.0" + resolved "https://registry.yarnpkg.com/dcmjs/-/dcmjs-0.14.0.tgz#0dc6cb2d15ddcff759bc9002f2a9704537735d1c" + integrity sha512-VL/Ibxe5RDsc5j5SEv3aEqdlKuBXz81/bBuW59Or0cos9vgK3XnVl3rr0ct6DWXJGK8vGIUMBOguyd/NRvlN0w== dependencies: "@babel/polyfill" "^7.8.3" "@babel/runtime" "^7.8.4"