diff --git a/platform/i18n/src/locales/en-US/ViewportDownloadForm.json b/platform/i18n/src/locales/en-US/ViewportDownloadForm.json new file mode 100644 index 000000000..cd1624b4b --- /dev/null +++ b/platform/i18n/src/locales/en-US/ViewportDownloadForm.json @@ -0,0 +1,14 @@ +{ + "emptyFilenameError": "The file name cannot be empty.", + "fileType": "File Type", + "filename": "File Name", + "formTitle": "Please specify the dimensions, filename, and desired type for the output image.", + "imageHeight": "Image height (px)", + "imagePreview": "Image Preview", + "imageWidth": "Image width (px)", + "keepAspectRatio": "Keep aspect ratio", + "loadingPreview": "Loading Image Preview...", + "minHeightError": "The minimum valid height is 100px.", + "minWidthError": "The minimum valid width is 100px.", + "showAnnotations": "Show Annotations" +} \ No newline at end of file diff --git a/platform/i18n/src/locales/en-US/index.js b/platform/i18n/src/locales/en-US/index.js index 3d3e49b1f..b21c68812 100644 --- a/platform/i18n/src/locales/en-US/index.js +++ b/platform/i18n/src/locales/en-US/index.js @@ -7,6 +7,7 @@ import Header from './Header.json'; import MeasurementTable from './MeasurementTable.json'; import StudyList from './StudyList.json'; import UserPreferencesModal from './UserPreferencesModal.json'; +import ViewportDownloadForm from './ViewportDownloadForm.json'; export default { 'en-US': { @@ -19,5 +20,6 @@ export default { MeasurementTable, StudyList, UserPreferencesModal, + ViewportDownloadForm, }, }; diff --git a/platform/ui/src/components/content/viewportDownloadForm/ViewportDownloadForm.js b/platform/ui/src/components/content/viewportDownloadForm/ViewportDownloadForm.js index fb9ddb595..367d66243 100644 --- a/platform/ui/src/components/content/viewportDownloadForm/ViewportDownloadForm.js +++ b/platform/ui/src/components/content/viewportDownloadForm/ViewportDownloadForm.js @@ -1,9 +1,16 @@ -import React, { useEffect, useState, createRef } from 'react'; +import React, { + useRef, + useCallback, + useEffect, + useState, + createRef, +} from 'react'; import PropTypes from 'prop-types'; import { useTranslation } from 'react-i18next'; import './ViewportDownloadForm.styl'; -import { TextInput, Select } from '@ohif/ui'; +import { TextInput, Select, Icon } from '@ohif/ui'; +import classnames from 'classnames'; const FILE_TYPE_OPTIONS = [ { @@ -17,6 +24,7 @@ const FILE_TYPE_OPTIONS = [ ]; const DEFAULT_FILENAME = 'image'; +const REFRESH_VIEWPORT_TIMEOUT = 1000; const ViewportDownloadForm = ({ activeViewport, @@ -32,7 +40,7 @@ const ViewportDownloadForm = ({ maximumSize, canvasClass, }) => { - const [t] = useTranslation('Buttons'); + const [t] = useTranslation('ViewportDownloadForm'); const [filename, setFilename] = useState(DEFAULT_FILENAME); const [fileType, setFileType] = useState('jpg'); @@ -44,6 +52,12 @@ const ViewportDownloadForm = ({ 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, @@ -62,41 +76,129 @@ const ViewportDownloadForm = ({ height: defaultSize, }); - // Cornerstone's `enable/disable` - useEffect(() => { - enableViewport(viewportElement); + const [error, setError] = useState({ + width: false, + height: false, + filename: false, + }); - return () => { - disableViewport(viewportElement); - }; - }, [disableViewport, enableViewport, viewportElement]); + const hasError = Object.values(error).includes(true); - useEffect(() => { - const { width, height } = viewportElementDimensions; - const validSize = value => (value >= minimumSize ? value : minimumSize); - const loadAndUpdateViewports = async () => { - await loadImage(activeViewport, viewportElement, width, height); - toggleAnnotations(showAnnotations, viewportElement); + const refreshViewport = useRef(null); - const { - dataUrl, - width: viewportElementWidth, - height: viewportElementHeight, - } = await updateViewportPreview( - viewportElement, - downloadCanvas.ref.current, - fileType + const downloadImage = () => { + downloadBlob( + filename || DEFAULT_FILENAME, + fileType, + viewportElement, + downloadCanvas.ref.current + ); + }; + + /** + * @param {object} event - Input change event + * @param {string} dimension - "height" | "width" + */ + const onDimensionsChange = (event, dimension) => { + const oppositeDimension = dimension === 'height' ? 'width' : 'height'; + const sanitizedTargetValue = event.target.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] ); + } - setViewportPreview(state => ({ + // 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, - src: dataUrl, - width: validSize(viewportElementWidth), - height: validSize(viewportElementHeight), + ...newDimensions, })); + } + }; + + const error_messages = { + width: t('minWidthError'), + height: t('minHeightError'), + filename: t('emptyFilenameError'), + }; + + const renderErrorHandler = errorType => { + if (!error[errorType]) { + return null; + } + + return
{error_messages[errorType]}
; + }; + + 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 validSize = value => (value >= minimumSize ? value : 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), }; - loadAndUpdateViewports(); + 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), + })); }, [ activeViewport, viewportElement, @@ -111,76 +213,96 @@ const ViewportDownloadForm = ({ viewportElementDimensions, ]); - /** - * @param {object} event - Input change event - * @param {string} dimension - "height" | "width" - */ - const onDimensionsChange = (event, dimension) => { - const sanitizedTargetValue = event.target.value.replace(/\D/, ''); - const isEmpty = sanitizedTargetValue === ''; - const updatedDimension = isEmpty - ? '' - : Math.min(sanitizedTargetValue, maximumSize); + useEffect(() => { + enableViewport(viewportElement); - if (updatedDimension === dimensions.width) { - return; + return () => { + disableViewport(viewportElement); + }; + }, [disableViewport, enableViewport, viewportElement]); + + useEffect(() => { + if (refreshViewport.current !== null) { + clearTimeout(refreshViewport.current); } - // In current code, keepAspect is always `true` - // And we always start w/ a square width/height - setDimensions({ - width: updatedDimension, - height: updatedDimension, - }); + refreshViewport.current = setTimeout(() => { + refreshViewport.current = null; + loadAndUpdateViewports(); + }, REFRESH_VIEWPORT_TIMEOUT); + }, [ + activeViewport, + viewportElement, + showAnnotations, + dimensions, + loadImage, + toggleAnnotations, + updateViewportPreview, + fileType, + downloadCanvas.ref, + minimumSize, + maximumSize, + ]); - // Only update if value is non-empty - if (!isEmpty) { - setViewportElementDimensions({ - height: updatedDimension, - width: updatedDimension, - }); - setDownloadCanvas(state => ({ - ...state, - height: updatedDimension, - width: updatedDimension, - })); - } - }; + useEffect(() => { + const { width, height } = dimensions; + const hasError = { + width: width < minimumSize, + height: height < minimumSize, + filename: !filename, + }; - const downloadImage = () => { - downloadBlob( - filename || DEFAULT_FILENAME, - fileType, - viewportElement, - downloadCanvas.ref.current - ); - }; + setError({ ...hasError }); + }, [dimensions, filename, minimumSize]); return (
-
- {t( - 'Please specify the dimensions, filename, and desired type for the output image.' - )} -
+
{t('formTitle')}
-
-
- onDimensionsChange(evt, 'height')} - /> +
+
+
+ onDimensionsChange(evt, 'width')} + data-cy="image-width" + /> + {renderErrorHandler('width')} +
+
+ onDimensionsChange(evt, 'height')} + data-cy="image-height" + /> + {renderErrorHandler('height')} +
-
- onDimensionsChange(evt, 'width')} - /> +
+
@@ -191,9 +313,10 @@ const ViewportDownloadForm = ({ data-cy="file-name" value={filename} onChange={event => setFilename(event.target.value)} - label={t('File name')} + label={t('filename')} id="file-name" /> + {renderErrorHandler('filename')}
- {this.props.options.map(({ key, value }) => { - return ( - - ); - })} - - + {this.props.label && ( + + )} +
); } diff --git a/platform/ui/src/elements/form/TextInput.js b/platform/ui/src/elements/form/TextInput.js index 13de72c65..430d4320a 100644 --- a/platform/ui/src/elements/form/TextInput.js +++ b/platform/ui/src/elements/form/TextInput.js @@ -14,7 +14,7 @@ class TextInput extends React.Component { PropTypes.number ]), id: PropTypes.string, - label:PropTypes.string, + label: PropTypes.string, type: PropTypes.string, }; @@ -28,15 +28,15 @@ class TextInput extends React.Component { render() { return (
- + {this.props.label && ( + + )} +
); } diff --git a/platform/viewer/cypress/integration/common/OHIFDownloadSnapshotFile.spec.js b/platform/viewer/cypress/integration/common/OHIFDownloadSnapshotFile.spec.js index aeda93764..3ef2153d4 100644 --- a/platform/viewer/cypress/integration/common/OHIFDownloadSnapshotFile.spec.js +++ b/platform/viewer/cypress/integration/common/OHIFDownloadSnapshotFile.spec.js @@ -16,14 +16,14 @@ describe('OHIF Download Snapshot File', () => { .click(); }); - it('checks displayed information for Tablet experience', function() { + it('checks displayed information for Tablet experience', function () { // Set Tablet resolution cy.viewport(1000, 660); // Visual comparison cy.screenshot('Download Image Modal - Tablet experience'); }); - it('checks displayed information for Desktop experience', function() { + it('checks displayed information for Desktop experience', function () { // Set Desktop resolution cy.viewport(1750, 720); // Visual comparison @@ -52,7 +52,7 @@ describe('OHIF Download Snapshot File', () => { .should('be.visible'); }); - it('cancel changes on download modal', function() { + it('cancel changes on download modal', function () { //Change Image Width, Filename and File Type cy.get('[data-cy="image-width"]') .clear() @@ -91,7 +91,7 @@ describe('OHIF Download Snapshot File', () => { // //Check error message // }); - it('checks if "Show Annotations" checkbox will display annotations', function() { + it('checks if "Show Annotations" checkbox will display annotations', function () { // Close modal that is initially opened cy.get('[data-cy="close-button"]').click();