From a538824cc715a18b8c205ecbd0292daf3f7834bf Mon Sep 17 00:00:00 2001 From: Rodrigo Antinarelli Date: Fri, 19 Jun 2020 20:59:09 -0300 Subject: [PATCH] feat: Modal + ViewportDownloadForm --- .../src/CornerstoneViewportDownloadForm.js | 6 +- platform/ui/index.js | 2 + platform/ui/src/assets/icons/link.svg | 11 + platform/ui/src/assets/icons/unlink.svg | 11 + platform/ui/src/components/Icon/getIcon.jsx | 4 + .../components/InputNumber/InputNumber.jsx | 50 +++ .../components/InputNumber/InputNumber.mdx | 44 ++ .../ui/src/components/InputNumber/index.js | 2 + .../ui/src/components/InputText/InputText.jsx | 12 +- platform/ui/src/components/Modal/Modal.jsx | 32 +- platform/ui/src/components/Select/Select.jsx | 5 + .../ViewportDownloadForm.jsx | 425 ++++++++++++++++++ .../components/ViewportDownloadForm/index.js | 1 + platform/ui/src/components/index.js | 4 + yarn.lock | 10 - 15 files changed, 592 insertions(+), 27 deletions(-) create mode 100644 platform/ui/src/assets/icons/link.svg create mode 100644 platform/ui/src/assets/icons/unlink.svg create mode 100644 platform/ui/src/components/InputNumber/InputNumber.jsx create mode 100644 platform/ui/src/components/InputNumber/InputNumber.mdx create mode 100644 platform/ui/src/components/InputNumber/index.js create mode 100644 platform/ui/src/components/ViewportDownloadForm/ViewportDownloadForm.jsx create mode 100644 platform/ui/src/components/ViewportDownloadForm/index.js 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/platform/ui/index.js b/platform/ui/index.js index db85039d9..4e819e60a 100644 --- a/platform/ui/index.js +++ b/platform/ui/index.js @@ -39,6 +39,7 @@ export { InputGroup, InputLabelWrapper, InputMultiSelect, + InputNumber, InputText, Label, LayoutSelector, @@ -73,6 +74,7 @@ export { Typography, Viewport, ViewportActionBar, + ViewportDownloadForm, ViewportGrid, ViewportPane, } from './src/components'; 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/Icon/getIcon.jsx b/platform/ui/src/components/Icon/getIcon.jsx index 145539060..7b1fc4dc2 100644 --- a/platform/ui/src/components/Icon/getIcon.jsx +++ b/platform/ui/src/components/Icon/getIcon.jsx @@ -16,6 +16,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 +31,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'; @@ -59,6 +61,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 +76,7 @@ const ICONS = { 'sorting-active-up': sortingActiveUp, sorting: sorting, tracked: tracked, + unlink: unlink, /** Tools */ 'tool-zoom': toolZoom, diff --git a/platform/ui/src/components/InputNumber/InputNumber.jsx b/platform/ui/src/components/InputNumber/InputNumber.jsx new file mode 100644 index 000000000..c6fca474f --- /dev/null +++ b/platform/ui/src/components/InputNumber/InputNumber.jsx @@ -0,0 +1,50 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { Input, InputLabelWrapper } from '@ohif/ui'; + +const InputNumber = ({ + label, + isSortable, + sortDirection, + onLabelClick, + value, + onChange, +}) => { + return ( + + { + onChange(event.target.value); + }} + /> + + ); +}; + +InputNumber.defaultProps = { + value: '', + isSortable: false, + onLabelClick: () => {}, + sortDirection: 'none', +}; + +InputNumber.propTypes = { + label: PropTypes.string.isRequired, + isSortable: PropTypes.bool, + sortDirection: PropTypes.oneOf(['ascending', 'descending', 'none']), + onLabelClick: PropTypes.func, + value: PropTypes.number, + onChange: PropTypes.func.isRequired, +}; + +export default InputNumber; diff --git a/platform/ui/src/components/InputNumber/InputNumber.mdx b/platform/ui/src/components/InputNumber/InputNumber.mdx new file mode 100644 index 000000000..6435c32ab --- /dev/null +++ b/platform/ui/src/components/InputNumber/InputNumber.mdx @@ -0,0 +1,44 @@ +--- +name: InputNumber +menu: Form +route: components/InputNumber +--- + +import { useState } from 'react'; +import { Playground, Props } from 'docz'; +import { InputNumber } from '@ohif/ui'; + +# Input Text + +## Import + +```javascript +import { InputNumber } from '@ohif/ui'; +``` + +## Basic usage + + + {() => { + const [number, setNumber] = useState(10); + return ( +
+
+ { + setText(value); + }} + /> +
+
+ ); + }} +
+ +## Properties + + diff --git a/platform/ui/src/components/InputNumber/index.js b/platform/ui/src/components/InputNumber/index.js new file mode 100644 index 000000000..566112449 --- /dev/null +++ b/platform/ui/src/components/InputNumber/index.js @@ -0,0 +1,2 @@ +import InputNumber from './InputNumber'; +export default InputNumber; diff --git a/platform/ui/src/components/InputText/InputText.jsx b/platform/ui/src/components/InputText/InputText.jsx index 2a244d7e8..fc62e3cdd 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,14 +33,16 @@ 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, + isSortable: PropTypes.bool, + sortDirection: PropTypes.oneOf(['ascending', 'descending', 'none']), + onLabelClick: PropTypes.func, value: PropTypes.string, onChange: PropTypes.func.isRequired, }; diff --git a/platform/ui/src/components/Modal/Modal.jsx b/platform/ui/src/components/Modal/Modal.jsx index 4ef669cbf..79fa9709e 100644 --- a/platform/ui/src/components/Modal/Modal.jsx +++ b/platform/ui/src/components/Modal/Modal.jsx @@ -3,13 +3,16 @@ import PropTypes from 'prop-types'; import ReactModal from 'react-modal'; import classNames from 'classnames'; +import { Typography } from '@ohif/ui'; + const customStyle = { overlay: { zIndex: 1071, - backgroundColor: 'rgb(0, 0, 0, 0.5)', + backgroundColor: 'rgb(0, 0, 0, 0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center', + padding: '40px 0', }, }; @@ -27,10 +30,14 @@ const Modal = ({ const renderHeader = () => { return ( title && ( -
-

{title}

+
+ {title} {closeButton && ( - )} @@ -41,20 +48,31 @@ const Modal = ({ return ( <> - {renderHeader()} -
{children}
+
{renderHeader()}
+
+ {children} +
); }; +Modal.defaultProps = { + shouldCloseOnEsc: true, +}; + Modal.propTypes = { className: PropTypes.string, closeButton: PropTypes.bool, diff --git a/platform/ui/src/components/Select/Select.jsx b/platform/ui/src/components/Select/Select.jsx index 3a47d8a6a..b51b64056 100644 --- a/platform/ui/src/components/Select/Select.jsx +++ b/platform/ui/src/components/Select/Select.jsx @@ -76,6 +76,11 @@ const Select = ({ options={options} value={selectedOptions} onChange={(selectedOptions, { action }) => { + if (!isMulti) { + onChange(selectedOptions, action); + return; + } + const newSelection = selectedOptions.reduce( (acc, curr) => acc.concat([curr.value]), [] diff --git a/platform/ui/src/components/ViewportDownloadForm/ViewportDownloadForm.jsx b/platform/ui/src/components/ViewportDownloadForm/ViewportDownloadForm.jsx new file mode 100644 index 000000000..6939ae0f7 --- /dev/null +++ b/platform/ui/src/components/ViewportDownloadForm/ViewportDownloadForm.jsx @@ -0,0 +1,425 @@ +import React, { + useCallback, + useEffect, + useState, + createRef, + useRef, +} from 'react'; + +import { + Typography, + InputText, + InputNumber, + 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 = 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), + }; + + 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 564522ab0..d67bbb9ad 100644 --- a/platform/ui/src/components/index.js +++ b/platform/ui/src/components/index.js @@ -10,6 +10,7 @@ import InputDateRange from './InputDateRange'; import InputGroup from './InputGroup'; import InputLabelWrapper from './InputLabelWrapper'; import InputMultiSelect from './InputMultiSelect'; +import InputNumber from './InputNumber'; import InputText from './InputText'; import Label from './Label'; import LayoutSelector from './LayoutSelector'; @@ -43,6 +44,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'; @@ -59,6 +61,7 @@ export { InputGroup, InputLabelWrapper, InputMultiSelect, + InputNumber, InputText, Label, LayoutSelector, @@ -93,6 +96,7 @@ export { Typography, Viewport, ViewportActionBar, + ViewportDownloadForm, ViewportGrid, ViewportPane, }; diff --git a/yarn.lock b/yarn.lock index b909ed6c5..eb88b95ac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17889,16 +17889,6 @@ react-cornerstone-viewport@2.3.8: prop-types "^15.7.2" react-resize-detector "^4.2.1" -react-cornerstone-viewport@2.3.9: - version "2.3.9" - resolved "https://registry.yarnpkg.com/react-cornerstone-viewport/-/react-cornerstone-viewport-2.3.9.tgz#f9761da8e536f0a217137c6ca1a983f5882249f9" - integrity sha512-qrhq8CbX/jq6b93cQjV2qC/mhHOvFBpxzxcHlvHzEQZt/rmRMcaYxCqjpNaNbUmmBu61wNkxesUVsggkPTTcqg== - dependencies: - classnames "^2.2.6" - date-fns "^2.2.1" - prop-types "^15.7.2" - react-resize-detector "^4.2.1" - react-dates@21.2.1: version "21.2.1" resolved "https://registry.yarnpkg.com/react-dates/-/react-dates-21.2.1.tgz#a979ed6876326ccfbf754a019bc95458cc061ad8"