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/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/ui/index.js b/platform/ui/index.js index 2025cfb3b..12b086abd 100644 --- a/platform/ui/index.js +++ b/platform/ui/index.js @@ -75,6 +75,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/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/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 04b5270cc..77247ac4d 100644 --- a/platform/ui/src/components/index.js +++ b/platform/ui/src/components/index.js @@ -45,6 +45,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'; @@ -97,6 +98,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: {