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')}
+
+
+
+
+
+
+ {}}
+ >
+
+
+
+
+
+
+
+
+
+
+
setViewportElement(ref)}
+ >
+
+
+
+ {viewportPreview.src ? (
+
+
Image 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"