diff --git a/platform/ui/index.js b/platform/ui/index.js index 827147317..4dd7ddb85 100644 --- a/platform/ui/index.js +++ b/platform/ui/index.js @@ -4,16 +4,22 @@ export { utils }; /** CONTEXT/HOOKS */ export { + DialogProvider, + useDialog, + withDialog, DragAndDropProvider, ModalProvider, ModalConsumer, useModal, withModal, - ViewportDialogProvider, - useViewportDialog, ImageViewerContext, ImageViewerProvider, useImageViewer, + SnackbarProvider, + useSnackbar, + withSnackbar, + ViewportDialogProvider, + useViewportDialog, ViewportGridContext, ViewportGridProvider, useViewportGrid, @@ -37,6 +43,7 @@ export { Label, MeasurementsPanel, MeasurementTable, + Modal, NavBar, Notification, Select, diff --git a/platform/ui/package.json b/platform/ui/package.json index 81bc781aa..add38db86 100644 --- a/platform/ui/package.json +++ b/platform/ui/package.json @@ -43,6 +43,7 @@ "react-dnd-html5-backend": "^10.0.2", "react-dnd-touch-backend": "^10.0.2", "react-dom": "16.11.0", + "react-modal": "^3.11.2", "react-powerplug": "1.0.0", "react-select": "^3.0.8", "theme-ui": "^0.2.38" diff --git a/platform/ui/src/components/Modal/Modal.jsx b/platform/ui/src/components/Modal/Modal.jsx new file mode 100644 index 000000000..4ef669cbf --- /dev/null +++ b/platform/ui/src/components/Modal/Modal.jsx @@ -0,0 +1,72 @@ +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', + }, +}; + +ReactModal.setAppElement(document.getElementById('root')); + +const Modal = ({ + className, + closeButton, + shouldCloseOnEsc, + isOpen, + title, + onClose, + children, +}) => { + const renderHeader = () => { + return ( + title && ( +
+

{title}

+ {closeButton && ( + + )} +
+ ) + ); + }; + + return ( + + <> + {renderHeader()} +
{children}
+ +
+ ); +}; + +Modal.propTypes = { + className: PropTypes.string, + closeButton: PropTypes.bool, + shouldCloseOnEsc: PropTypes.bool, + isOpen: PropTypes.bool, + title: PropTypes.string, + onClose: PropTypes.func, + /** The modal's content */ + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + ]).isRequired, +}; + +export default Modal; diff --git a/platform/ui/src/components/Modal/index.js b/platform/ui/src/components/Modal/index.js new file mode 100644 index 000000000..f44559f49 --- /dev/null +++ b/platform/ui/src/components/Modal/index.js @@ -0,0 +1,2 @@ +import Modal from './Modal'; +export default Modal; diff --git a/platform/ui/src/components/Snackbar/SnackbarContainer.jsx b/platform/ui/src/components/Snackbar/SnackbarContainer.jsx new file mode 100644 index 000000000..eb0e83fb2 --- /dev/null +++ b/platform/ui/src/components/Snackbar/SnackbarContainer.jsx @@ -0,0 +1,52 @@ +import React from 'react'; +import SnackbarItem from './SnackbarItem'; +import { useSnackbar } from '../../contextProviders'; + +const SnackbarContainer = () => { + const { snackbarItems, hide } = useSnackbar(); + + const renderItem = item => { + return ; + }; + + if (!snackbarItems) { + return null; + } + + const renderItems = () => { + const items = { + topLeft: [], + topCenter: [], + topRight: [], + bottomLeft: [], + bottomCenter: [], + bottomRight: [], + }; + + snackbarItems.map(item => { + items[item.position].push(item); + }); + + return ( +
+ {Object.keys(items).map(pos => { + if (!items[pos].length) { + return null; + } + + return ( +
+ {items[pos].map((item, index) => ( +
{renderItem(item)}
+ ))} +
+ ); + })} +
+ ); + }; + + return <>{renderItems()}; +}; + +export default SnackbarContainer; diff --git a/platform/ui/src/components/Snackbar/SnackbarItem.jsx b/platform/ui/src/components/Snackbar/SnackbarItem.jsx new file mode 100644 index 000000000..abeef2ccf --- /dev/null +++ b/platform/ui/src/components/Snackbar/SnackbarItem.jsx @@ -0,0 +1,27 @@ +import React, { useEffect } from 'react'; + +const SnackbarItem = ({ options, onClose }) => { + const handleClose = () => { + onClose(options.id); + }; + + useEffect(() => { + if (options.autoClose) { + setTimeout(() => { + handleClose(); + }, options.duration); + } + }, []); + + return ( +
+ + x + + {options.title &&
{options.title}
} + {options.message &&
{options.message}
} +
+ ); +}; + +export default SnackbarItem; diff --git a/platform/ui/src/components/Snackbar/SnackbarTypes.js b/platform/ui/src/components/Snackbar/SnackbarTypes.js new file mode 100644 index 000000000..3dffcb5c9 --- /dev/null +++ b/platform/ui/src/components/Snackbar/SnackbarTypes.js @@ -0,0 +1,6 @@ +export default { + INFO: 'info', + WARNING: 'warning', + SUCCESS: 'success', + ERROR: 'error', +}; diff --git a/platform/ui/src/components/index.js b/platform/ui/src/components/index.js index 9a5589f90..cc224c2bd 100644 --- a/platform/ui/src/components/index.js +++ b/platform/ui/src/components/index.js @@ -14,6 +14,7 @@ import InputText from './InputText'; import Label from './Label'; import MeasurementsPanel from './MeasurementsPanel'; import MeasurementTable from './MeasurementTable'; +import Modal from './Modal'; import NavBar from './NavBar'; import Notification from './Notification'; import Select from './Select'; @@ -62,6 +63,7 @@ export { Label, MeasurementsPanel, MeasurementTable, + Modal, NavBar, Notification, Select, diff --git a/platform/ui/src/contextProviders/DialogProvider.jsx b/platform/ui/src/contextProviders/DialogProvider.jsx new file mode 100644 index 000000000..eb3c207cb --- /dev/null +++ b/platform/ui/src/contextProviders/DialogProvider.jsx @@ -0,0 +1,280 @@ +import React, { + useState, + createContext, + useContext, + useCallback, + useEffect, +} from 'react'; +import PropTypes from 'prop-types'; +import Draggable from 'react-draggable'; +import classNames from 'classnames'; +import { utils } from '@ohif/core'; + + +const DialogContext = createContext(null); + +export const useDialog = () => useContext(DialogContext); + +const DialogProvider = ({ children, service }) => { + const [isDragging, setIsDragging] = useState(false); + const [dialogs, setDialogs] = useState([]); + const [lastDialogId, setLastDialogId] = useState(null); + const [lastDialogPosition, setLastDialogPosition] = useState(null); + const [centerPositions, setCenterPositions] = useState([]); + + useEffect(() => { + setCenterPositions( + dialogs.map(dialog => ({ + id: dialog.id, + ...getCenterPosition(dialog.id), + })) + ); + }, [dialogs]); + + const getCenterPosition = id => { + const root = document.querySelector('#root'); + const centerX = root.offsetLeft + root.offsetWidth / 2; + const centerY = root.offsetTop + root.offsetHeight / 2; + const item = document.querySelector(`#draggableItem-${id}`); + const itemBounds = item.getBoundingClientRect(); + return { + x: centerX - itemBounds.width / 2, + y: centerY - itemBounds.height / 2, + }; + }; + + /** + * Sets the implementation of a dialog service that can be used by extensions. + * + * @returns void + */ + useEffect(() => { + if (service) { + service.setServiceImplementation({ create, dismiss, dismissAll }); + } + }, [create, dismiss, service]); + + /** + * UI Dialog + * + * @typedef {Object} DialogProps + * @property {string} id The dialog id. + * @property {DialogContent} content The dialog content. + * @property {Object} contentProps The dialog content props. + * @property {boolean} isDraggable Controls if dialog content is draggable or not. + * @property {boolean} showOverlay Controls dialog overlay. + * @property {boolean} centralize Center the dialog on the screen. + * @property {boolean} preservePosition Use last position instead of default. + * @property {ElementPosition} defaultPosition Specifies the `x` and `y` that the dragged item should start at. + * @property {Function} onStart Called when dragging starts. If `false` is returned any handler, the action will cancel. + * @property {Function} onStop Called when dragging stops. + * @property {Function} onDrag Called while dragging. + */ + + useEffect(() => _bringToFront(lastDialogId), [_bringToFront, lastDialogId]); + + /** + * Creates a new dialog and return its id. + * + * @param {DialogProps} props The dialog props. + * @returns The new dialog id. + */ + const create = useCallback(props => { + const { id } = props; + + let dialogId = id; + if (!dialogId) { + dialogId = utils.guid(); + } + + setDialogs(dialogs => [...dialogs, { ...props, id: dialogId }]); + setLastDialogId(dialogId); + + return dialogId; + }, []); + + /** + * Dismisses the dialog with a given id. + * + * @param {Object} props - + * @property {string} props.id The dialog id. + * @returns void + */ + const dismiss = useCallback( + ({ id }) => + setDialogs(dialogs => dialogs.filter(dialog => dialog.id !== id)), + [] + ); + + /** + * Dismisses all dialogs. + * + * @returns void + */ + const dismissAll = () => { + setDialogs([]); + }; + + /** + * Indicate if there are no dialogs present. + * + * @returns True if no dialogs are present. + */ + const isEmpty = () => dialogs && dialogs.length < 1; + + /** + * Moves the dialog to the foreground if clicked. + * + * @param {string} id The dialog id. + * @returns void + */ + const _bringToFront = useCallback(id => { + setDialogs(dialogs => { + const topDialog = dialogs.find(dialog => dialog.id === id); + return topDialog + ? [...dialogs.filter(dialog => dialog.id !== id), topDialog] + : dialogs; + }); + }, []); + + const renderDialogs = () => + dialogs.map(dialog => { + const { + id, + content: DialogContent, + contentProps, + defaultPosition, + centralize = false, + preservePosition = true, + isDraggable = true, + onStart, + onStop, + onDrag, + } = dialog; + + let position = + (preservePosition && lastDialogPosition) || defaultPosition; + if (centralize) { + position = centerPositions.find(position => position.id === id); + } + + return ( + { + const e = event || window.event; + const target = e.target || e.srcElement; + const BLACKLIST = [ + 'SVG', + 'BUTTON', + 'PATH', + 'INPUT', + 'SPAN', + 'LABEL', + ]; + if (BLACKLIST.includes(target.tagName.toUpperCase())) { + return false; + } + + if (validCallback(onStart)) { + return onStart(event); + } + }} + onStop={event => { + setIsDragging(false); + + if (validCallback(onStop)) { + return onStop(event); + } + }} + onDrag={event => { + setIsDragging(true); + _bringToFront(id); + _updateLastDialogPosition(id); + + if (validCallback(onDrag)) { + return onDrag(event); + } + }} + > +
_bringToFront(id)} + > + +
+
+ ); + }); + + /** + * Update the last dialog position to be used as the new default position. + * + * @returns void + */ + const _updateLastDialogPosition = dialogId => { + const draggableItemBounds = document + .querySelector(`#draggableItem-${dialogId}`) + .getBoundingClientRect(); + setLastDialogPosition({ + x: draggableItemBounds.x, + y: draggableItemBounds.y, + }); + }; + + const validCallback = callback => callback && typeof callback === 'function'; + + return ( + +
+ {dialogs.some(dialog => dialog.showOverlay) ? ( +
{renderDialogs()}
+ ) : ( + renderDialogs() + )} +
+ {children} +
+ ); +}; + +/** + * + * High Order Component to use the dialog methods through a Class Component + * + */ +export const withDialog = Component => { + return function WrappedComponent(props) { + const { create, dismiss, dismissAll, isEmpty } = useDialog(); + return ( + + ); + }; +}; + +DialogProvider.defaultProps = { + service: null, +}; + +DialogProvider.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + PropTypes.func, + ]).isRequired, + service: PropTypes.shape({ + setServiceImplementation: PropTypes.func, + }), +}; + +export default DialogProvider; diff --git a/platform/ui/src/contextProviders/ModalProvider.js b/platform/ui/src/contextProviders/ModalProvider.jsx similarity index 95% rename from platform/ui/src/contextProviders/ModalProvider.js rename to platform/ui/src/contextProviders/ModalProvider.jsx index 74a53c02f..e8d65635d 100644 --- a/platform/ui/src/contextProviders/ModalProvider.js +++ b/platform/ui/src/contextProviders/ModalProvider.jsx @@ -39,6 +39,17 @@ const ModalProvider = ({ children, modal: Modal, service }) => { const [options, setOptions] = useState(DEFAULT_OPTIONS); + /** + * Sets the implementation of a modal service that can be used by extensions. + * + * @returns void + */ + useEffect(() => { + if (service) { + service.setServiceImplementation({ hide, show }); + } + }, [hide, service, show]); + /** * Show the modal and override its configuration props. * @@ -58,17 +69,6 @@ const ModalProvider = ({ children, modal: Modal, service }) => { DEFAULT_OPTIONS, ]); - /** - * Sets the implementation of a modal service that can be used by extensions. - * - * @returns void - */ - useEffect(() => { - if (service) { - service.setServiceImplementation({ hide, show }); - } - }, [hide, service, show]); - const { content: ModalContent, contentProps, @@ -115,18 +115,15 @@ ModalProvider.defaultProps = { }; ModalProvider.propTypes = { - /** Children that will be wrapped with Modal Context */ children: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node, ]).isRequired, - /** Modal component */ modal: PropTypes.oneOfType([ PropTypes.arrayOf(PropTypes.node), PropTypes.node, PropTypes.func, ]).isRequired, - /** service to be update once modal provider is instanciated */ service: PropTypes.shape({ setServiceImplementation: PropTypes.func, }), diff --git a/platform/ui/src/contextProviders/SnackbarProvider.jsx b/platform/ui/src/contextProviders/SnackbarProvider.jsx new file mode 100644 index 000000000..33d93b749 --- /dev/null +++ b/platform/ui/src/contextProviders/SnackbarProvider.jsx @@ -0,0 +1,142 @@ +import React, { + useState, + createContext, + useContext, + useCallback, + useEffect, +} from 'react'; +import PropTypes from 'prop-types'; + +import SnackbarContainer from '../components/Snackbar/SnackbarContainer'; +import SnackbarTypes from '../components/Snackbar/SnackbarTypes'; + +const SnackbarContext = createContext(null); + +export const useSnackbar = () => useContext(SnackbarContext); + +const SnackbarProvider = ({ children, service }) => { + const DEFAULT_OPTIONS = { + title: '', + message: '', + duration: 5000, + autoClose: true, + position: 'bottomRight', + type: SnackbarTypes.INFO, + }; + + const [count, setCount] = useState(1); + const [snackbarItems, setSnackbarItems] = useState([]); + + /** + * Sets the implementation of a notification service that can be used by extensions. + * + * @returns void + */ + useEffect(() => { + if (service) { + service.setServiceImplementation({ hide, show }); + } + }, [service, hide, show]); + + const show = useCallback( + options => { + if (!options || (!options.title && !options.message)) { + console.warn( + 'Snackbar cannot be rendered without required parameters: title | message' + ); + + return null; + } + + const newItem = { + ...DEFAULT_OPTIONS, + ...options, + id: count, + visible: true, + }; + + setSnackbarItems(state => [...state, newItem]); + setCount(count + 1); + }, + [count, DEFAULT_OPTIONS] + ); + + const hide = useCallback( + id => { + const hideItem = items => { + const newItems = items.map(item => { + if (item.id === id) { + item.visible = false; + } + + return item; + }); + + return newItems; + }; + + setSnackbarItems(state => hideItem(state)); + + setTimeout(() => { + setSnackbarItems(state => [...state.filter(item => item.id !== id)]); + }, 1000); + }, + [setSnackbarItems] + ); + + const hideAll = () => { + // reset count + setCount(1); + + // remove all items from array + setSnackbarItems(() => []); + }; + + /** + * expose snackbar methods to window for debug purposes + * TODO: Check if it's really necessary + */ + window.snackbar = { + show, + hide, + hideAll, + }; + + return ( + + {!!snackbarItems && } + {children} + + ); +}; + +SnackbarProvider.defaultProps = { + service: null, +}; + +SnackbarProvider.propTypes = { + children: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.node), + PropTypes.node, + PropTypes.func, + ]).isRequired, + service: PropTypes.shape({ + setServiceImplementation: PropTypes.func, + }), +}; + +/** + * + * High Order Component to use the snackbar methods through a Class Component + * + */ +export const withSnackbar = Component => { + return function WrappedComponent(props) { + const snackbarContext = { + ...useSnackbarContext(), + }; + return ; + }; +}; + +export default SnackbarProvider; diff --git a/platform/ui/src/contextProviders/index.js b/platform/ui/src/contextProviders/index.js index 571ce4294..6874f1a07 100644 --- a/platform/ui/src/contextProviders/index.js +++ b/platform/ui/src/contextProviders/index.js @@ -1,3 +1,11 @@ +export { + default as DialogProvider, + useDialog, + withDialog, +} from './DialogProvider' + +export { default as DragAndDropProvider } from './DragAndDropProvider'; + export { default as ModalProvider, useModal, @@ -5,21 +13,25 @@ export { ModalConsumer, } from './ModalProvider'; -export { - default as ViewportDialogProvider, - useViewportDialog, -} from './ViewportDialogProvider'; - export { ImageViewerContext, ImageViewerProvider, useImageViewer, } from './ImageViewerProvider'; +export { + default as SnackbarProvider, + useSnackbar, + withSnackbar, +} from './SnackbarProvider' + +export { + default as ViewportDialogProvider, + useViewportDialog, +} from './ViewportDialogProvider'; + export { ViewportGridContext, ViewportGridProvider, useViewportGrid, } from './ViewportGridProvider'; - -export { default as DragAndDropProvider } from './DragAndDropProvider'; diff --git a/platform/viewer/src/App.jsx b/platform/viewer/src/App.jsx index 28ab3a4e9..46014c33b 100644 --- a/platform/viewer/src/App.jsx +++ b/platform/viewer/src/App.jsx @@ -2,7 +2,13 @@ import React from 'react'; import PropTypes from 'prop-types'; import { BrowserRouter, HashRouter } from 'react-router-dom'; -import { ThemeWrapper } from '@ohif/ui'; +import { + DialogProvider, + Modal, + ModalProvider, + SnackbarProvider, + ThemeWrapper, +} from '@ohif/ui'; // Viewer Project // TODO: Should this influence study list? import { appConfigContext } from '@state/appConfig.context'; @@ -10,7 +16,7 @@ import { useAppConfig } from '@hooks/useAppConfig'; import createRoutes from './routes'; import appInit from './appInit.js'; -// Temporarily for testing +// TODO: Temporarily for testing import '@ohif/mode-example'; /** @@ -40,11 +46,20 @@ function App({ config, defaultExtensions }) { extensionManager, servicesManager ); + const { UIDialogService, UIModalService, UINotificationService } = servicesManager.services; return ( - {appRoutes} + + + + + {appRoutes} + + + + ); diff --git a/yarn.lock b/yarn.lock index b05065346..bcd4cdce0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -868,13 +868,27 @@ pirates "^4.0.0" source-map-support "^0.5.16" -"@babel/runtime@7.1.2", "@babel/runtime@7.7.6", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6", "@babel/runtime@^7.7.7", "@babel/runtime@^7.8.4": +"@babel/runtime@7.1.2": + version "7.1.2" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.1.2.tgz#81c89935f4647706fc54541145e6b4ecfef4b8e3" + integrity sha512-Y3SCjmhSupzFB6wcv1KmmFucH6gDVnI30WjOcicV10ju0cZjak3Jcs67YLIXBrmZYw1xCrVeJPbycFwrqNyxpg== + dependencies: + regenerator-runtime "^0.12.0" + +"@babel/runtime@7.7.6", "@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.2.0", "@babel/runtime@^7.3.1", "@babel/runtime@^7.3.4", "@babel/runtime@^7.4.4", "@babel/runtime@^7.4.5", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.7.2", "@babel/runtime@^7.7.4", "@babel/runtime@^7.7.6": version "7.7.6" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.7.6.tgz#d18c511121aff1b4f2cd1d452f1bac9601dd830f" integrity sha512-BWAJxpNVa0QlE5gZdWjSxXtemZyZ9RmrmVozxt3NUXeZhVIJ5ANyqmMc0JDrivBZyxUuQvFxlvH4OWWOogGfUw== dependencies: regenerator-runtime "^0.13.2" +"@babel/runtime@^7.7.7", "@babel/runtime@^7.8.4": + version "7.9.6" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.9.6.tgz#a9102eb5cadedf3f31d08a9ecf294af7827ea29f" + integrity sha512-64AF1xY3OAkFHqOb9s4jpgk1Mm5vDZ4L3acHvAml+53nO1XbXLuDodsVpO4OIUsmemlUHMxNdYMNJmsvOwLrvQ== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/standalone@^7.4.5": version "7.8.6" resolved "https://registry.yarnpkg.com/@babel/standalone/-/standalone-7.8.6.tgz#1364534775c83bf7b7988e4ca98823bef56a0a53" @@ -16720,7 +16734,7 @@ react-live@^2.2.1: react-simple-code-editor "^0.10.0" unescape "^1.0.1" -react-modal@^3.11.1: +react-modal@^3.11.1, react-modal@^3.11.2: version "3.11.2" resolved "https://registry.yarnpkg.com/react-modal/-/react-modal-3.11.2.tgz#bad911976d4add31aa30dba8a41d11e21c4ac8a4" integrity sha512-o8gvvCOFaG1T7W6JUvsYjRjMVToLZgLIsi5kdhFIQCtHxDkA47LznX62j+l6YQkpXDbvQegsDyxe/+JJsFQN7w== @@ -17282,6 +17296,11 @@ regenerator-runtime@^0.11.0: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz#be05ad7f9bf7d22e056f9726cee5017fbf19e2e9" integrity sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg== +regenerator-runtime@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.12.1.tgz#fa1a71544764c036f8c49b13a08b2594c9f8a0de" + integrity sha512-odxIc1/vDlo4iZcfXqRYFj0vpXFNoGdKMAUieAlFYO6m/nl5e9KR/beGf41z4a1FI+aQgtjhuaSlDxQ0hmkrHg== + regenerator-runtime@^0.13.1, regenerator-runtime@^0.13.2: version "0.13.3" resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz#7cf6a77d8f5c6f60eb73c5fc1955b2ceb01e6bf5"