From fc1e4b4b6c5e1efc3925108d5e016d4108d1e85f Mon Sep 17 00:00:00 2001 From: Linda Paiste Date: Sat, 6 Aug 2022 14:44:18 -0500 Subject: [PATCH 1/8] Combined Modal component for New File, New Folder, and Upload. --- client/modules/IDE/components/Modal.jsx | 64 +++++++++++ .../modules/IDE/components/NewFileModal.jsx | 93 +++------------- .../modules/IDE/components/NewFolderModal.jsx | 75 ++++--------- .../IDE/components/UploadFileModal.jsx | 100 ++++++------------ client/modules/IDE/pages/IDEView.jsx | 8 +- 5 files changed, 133 insertions(+), 207 deletions(-) create mode 100644 client/modules/IDE/components/Modal.jsx diff --git a/client/modules/IDE/components/Modal.jsx b/client/modules/IDE/components/Modal.jsx new file mode 100644 index 0000000000..cdbd217327 --- /dev/null +++ b/client/modules/IDE/components/Modal.jsx @@ -0,0 +1,64 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React, { useEffect, useRef } from 'react'; +import ExitIcon from '../../../images/exit.svg'; + +// Common logic from NewFolderModal, NewFileModal, UploadFileModal + +const Modal = ({ + title, + onClose, + closeAriaLabel, + contentClassName, + children +}) => { + const modalRef = useRef(null); + + const handleOutsideClick = (e) => { + // ignore clicks on the component itself + if (e.path.includes(modalRef.current)) return; + + onClose(); + }; + + useEffect(() => { + modalRef.current.focus(); + document.addEventListener('click', handleOutsideClick, false); + + return () => { + document.removeEventListener('click', handleOutsideClick, false); + }; + }, []); + + return ( +
+
+
+

{title}

+ +
+ {children} +
+
+ ); +}; + +Modal.propTypes = { + title: PropTypes.string.isRequired, + onClose: PropTypes.func.isRequired, + closeAriaLabel: PropTypes.string.isRequired, + contentClassName: PropTypes.string, + children: PropTypes.node.isRequired +}; + +Modal.defaultProps = { + contentClassName: '' +}; + +export default Modal; diff --git a/client/modules/IDE/components/NewFileModal.jsx b/client/modules/IDE/components/NewFileModal.jsx index d85cf44ea2..bf56249279 100644 --- a/client/modules/IDE/components/NewFileModal.jsx +++ b/client/modules/IDE/components/NewFileModal.jsx @@ -1,83 +1,22 @@ -import PropTypes from 'prop-types'; import React from 'react'; -import { connect } from 'react-redux'; -import { bindActionCreators } from 'redux'; -import { withTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { useTranslation } from 'react-i18next'; +import Modal from './Modal'; import NewFileForm from './NewFileForm'; import { closeNewFileModal } from '../actions/ide'; -import ExitIcon from '../../../images/exit.svg'; -// At some point this will probably be generalized to a generic modal -// in which you can insert different content -// but for now, let's just make this work -class NewFileModal extends React.Component { - constructor(props) { - super(props); - this.focusOnModal = this.focusOnModal.bind(this); - this.handleOutsideClick = this.handleOutsideClick.bind(this); - } - - componentDidMount() { - this.focusOnModal(); - document.addEventListener('click', this.handleOutsideClick, false); - } - - componentWillUnmount() { - document.removeEventListener('click', this.handleOutsideClick, false); - } - - handleOutsideClick(e) { - // ignore clicks on the component itself - if (e.path.includes(this.modal)) return; - - this.props.closeNewFileModal(); - } - - focusOnModal() { - this.modal.focus(); - } - - render() { - return ( -
{ - this.modal = element; - }} - > -
-
-

- {this.props.t('NewFileModal.Title')} -

- -
- -
-
- ); - } -} - -NewFileModal.propTypes = { - closeNewFileModal: PropTypes.func.isRequired, - t: PropTypes.func.isRequired +const NewFileModal = () => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + return ( + dispatch(closeNewFileModal())} + > + + + ); }; -function mapStateToProps() { - return {}; -} - -function mapDispatchToProps(dispatch) { - return bindActionCreators({ closeNewFileModal }, dispatch); -} - -export default withTranslation()( - connect(mapStateToProps, mapDispatchToProps)(NewFileModal) -); +export default NewFileModal; diff --git a/client/modules/IDE/components/NewFolderModal.jsx b/client/modules/IDE/components/NewFolderModal.jsx index fc5161ddc0..bd521ae970 100644 --- a/client/modules/IDE/components/NewFolderModal.jsx +++ b/client/modules/IDE/components/NewFolderModal.jsx @@ -1,62 +1,23 @@ -import PropTypes from 'prop-types'; import React from 'react'; -import { withTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { closeNewFolderModal } from '../actions/ide'; +import Modal from './Modal'; import NewFolderForm from './NewFolderForm'; -import ExitIcon from '../../../images/exit.svg'; -class NewFolderModal extends React.Component { - constructor(props) { - super(props); - this.handleOutsideClick = this.handleOutsideClick.bind(this); - } - - componentDidMount() { - this.newFolderModal.focus(); - document.addEventListener('click', this.handleOutsideClick, false); - } - - componentWillUnmount() { - document.removeEventListener('click', this.handleOutsideClick, false); - } - - handleOutsideClick(e) { - // ignore clicks on the component itself - if (e.path.includes(this.newFolderModal)) return; - - this.props.closeModal(); - } - - render() { - return ( -
{ - this.newFolderModal = element; - }} - > -
-
-

- {this.props.t('NewFolderModal.Title')} -

- -
- -
-
- ); - } -} - -NewFolderModal.propTypes = { - closeModal: PropTypes.func.isRequired, - t: PropTypes.func.isRequired +const NewFolderModal = () => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + return ( + dispatch(closeNewFolderModal())} + contentClassName="modal-content-folder" + > + + + ); }; -export default withTranslation()(NewFolderModal); +export default NewFolderModal; diff --git a/client/modules/IDE/components/UploadFileModal.jsx b/client/modules/IDE/components/UploadFileModal.jsx index f2a9c09d1d..20aefc960b 100644 --- a/client/modules/IDE/components/UploadFileModal.jsx +++ b/client/modules/IDE/components/UploadFileModal.jsx @@ -1,79 +1,45 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router'; -import { withTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; import prettyBytes from 'pretty-bytes'; import getConfig from '../../../utils/getConfig'; +import { closeUploadFileModal } from '../actions/ide'; import FileUploader from './FileUploader'; import { getreachedTotalSizeLimit } from '../selectors/users'; -import ExitIcon from '../../../images/exit.svg'; +import Modal from './Modal'; const limit = getConfig('UPLOAD_LIMIT') || 250000000; const limitText = prettyBytes(limit); -class UploadFileModal extends React.Component { - static propTypes = { - reachedTotalSizeLimit: PropTypes.bool.isRequired, - closeModal: PropTypes.func.isRequired, - t: PropTypes.func.isRequired - }; - - componentDidMount() { - this.focusOnModal(); - } - - focusOnModal = () => { - this.modal.focus(); - }; - - render() { - return ( -
{ - this.modal = element; - }} - > -
-
-

- {this.props.t('UploadFileModal.Title')} -

- -
- {this.props.reachedTotalSizeLimit && ( -

- {this.props.t('UploadFileModal.SizeLimitError', { - sizeLimit: limitText - })} - - assets - - . -

- )} - {!this.props.reachedTotalSizeLimit && ( -
- -
- )} +const UploadFileModal = () => { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const reachedTotalSizeLimit = useSelector(getreachedTotalSizeLimit); + const onClose = () => dispatch(closeUploadFileModal()); + return ( + + {reachedTotalSizeLimit ? ( +

+ {t('UploadFileModal.SizeLimitError', { + sizeLimit: limitText + })} + + assets + + . +

+ ) : ( +
+
-
- ); - } -} - -function mapStateToProps(state) { - return { - reachedTotalSizeLimit: getreachedTotalSizeLimit(state) - }; -} + )} + + ); +}; -export default withTranslation()(connect(mapStateToProps)(UploadFileModal)); +export default UploadFileModal; diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index 04bbb2f9ad..05870c8de9 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -379,12 +379,8 @@ class IDEView extends React.Component { {this.props.ide.modalIsVisible && } - {this.props.ide.newFolderModalVisible && ( - - )} - {this.props.ide.uploadFileModalVisible && ( - - )} + {this.props.ide.newFolderModalVisible && } + {this.props.ide.uploadFileModalVisible && } {this.props.location.pathname === '/about' && ( Date: Sun, 7 Aug 2022 14:31:25 -0500 Subject: [PATCH 2/8] Move keydown handling out of IDEView --- client/components/Nav.jsx | 16 ++-- client/modules/App/components/Overlay.jsx | 13 +-- .../modules/IDE/components/IDEKeyHandlers.jsx | 93 ++++++++++++++++++ client/modules/IDE/components/Modal.jsx | 3 + .../modules/IDE/hooks/useKeyDownHandlers.js | 55 +++++++++++ client/modules/IDE/pages/IDEView.jsx | 96 +------------------ client/modules/IDE/pages/MobileIDEView.jsx | 89 +---------------- client/modules/IDE/selectors/users.js | 4 +- 8 files changed, 168 insertions(+), 201 deletions(-) create mode 100644 client/modules/IDE/components/IDEKeyHandlers.jsx create mode 100644 client/modules/IDE/hooks/useKeyDownHandlers.js diff --git a/client/components/Nav.jsx b/client/components/Nav.jsx index a7b612a20d..12dc6fdb4d 100644 --- a/client/components/Nav.jsx +++ b/client/components/Nav.jsx @@ -13,6 +13,7 @@ import { setAllAccessibleOutput, setLanguage } from '../modules/IDE/actions/preferences'; +import { DocumentKeyDown } from '../modules/IDE/hooks/useKeyDownHandlers'; import { logoutUser } from '../modules/User/actions'; import getConfig from '../utils/getConfig'; @@ -63,17 +64,13 @@ class Nav extends React.PureComponent { this.toggleDropdownForLang = this.toggleDropdown.bind(this, 'lang'); this.handleFocusForLang = this.handleFocus.bind(this, 'lang'); this.handleLangSelection = this.handleLangSelection.bind(this); - - this.closeDropDown = this.closeDropDown.bind(this); } componentDidMount() { document.addEventListener('mousedown', this.handleClick, false); - document.addEventListener('keydown', this.closeDropDown, false); } componentWillUnmount() { document.removeEventListener('mousedown', this.handleClick, false); - document.removeEventListener('keydown', this.closeDropDown, false); } setDropdown(dropdown) { this.setState({ @@ -81,12 +78,6 @@ class Nav extends React.PureComponent { }); } - closeDropDown(e) { - if (e.keyCode === 27) { - this.setDropdown('none'); - } - } - handleClick(e) { if (!this.node) { return; @@ -904,6 +895,11 @@ class Nav extends React.PureComponent { {this.renderLeftLayout(navDropdownState)} {this.renderUserMenu(navDropdownState)} + this.setDropdown('none') + }} + /> ); } diff --git a/client/modules/App/components/Overlay.jsx b/client/modules/App/components/Overlay.jsx index 39f492fc6f..53bda34f48 100644 --- a/client/modules/App/components/Overlay.jsx +++ b/client/modules/App/components/Overlay.jsx @@ -4,6 +4,7 @@ import { browserHistory } from 'react-router'; import { withTranslation } from 'react-i18next'; import ExitIcon from '../../../images/exit.svg'; +import { DocumentKeyDown } from '../../IDE/hooks/useKeyDownHandlers'; class Overlay extends React.Component { constructor(props) { @@ -11,12 +12,10 @@ class Overlay extends React.Component { this.close = this.close.bind(this); this.handleClick = this.handleClick.bind(this); this.handleClickOutside = this.handleClickOutside.bind(this); - this.keyPressHandle = this.keyPressHandle.bind(this); } componentWillMount() { document.addEventListener('mousedown', this.handleClick, false); - document.addEventListener('keydown', this.keyPressHandle); } componentDidMount() { @@ -25,7 +24,6 @@ class Overlay extends React.Component { componentWillUnmount() { document.removeEventListener('mousedown', this.handleClick, false); - document.removeEventListener('keydown', this.keyPressHandle); } handleClick(e) { @@ -40,14 +38,6 @@ class Overlay extends React.Component { this.close(); } - keyPressHandle(e) { - // escape key code = 27. - // So here we are checking if the key pressed was Escape key. - if (e.keyCode === 27) { - this.close(); - } - } - close() { // Only close if it is the last (and therefore the topmost overlay) const overlays = document.getElementsByClassName('overlay'); @@ -90,6 +80,7 @@ class Overlay extends React.Component { {children} + this.close() }} /> diff --git a/client/modules/IDE/components/IDEKeyHandlers.jsx b/client/modules/IDE/components/IDEKeyHandlers.jsx new file mode 100644 index 0000000000..6578753f88 --- /dev/null +++ b/client/modules/IDE/components/IDEKeyHandlers.jsx @@ -0,0 +1,93 @@ +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { updateFileContent } from '../actions/files'; +import { + collapseConsole, + collapseSidebar, + expandConsole, + expandSidebar, + showErrorModal, + startSketch, + stopSketch +} from '../actions/ide'; +import { setAllAccessibleOutput } from '../actions/preferences'; +import { cloneProject, saveProject } from '../actions/project'; +import useKeyDownHandlers from '../hooks/useKeyDownHandlers'; +import { + getAuthenticated, + getIsUserOwner, + getSketchOwner +} from '../selectors/users'; + +export const useIDEKeyHandlers = ({ getContent }) => { + const dispatch = useDispatch(); + + const sidebarIsExpanded = useSelector((state) => state.ide.sidebarIsExpanded); + const consoleIsExpanded = useSelector((state) => state.ide.consoleIsExpanded); + + const isUserOwner = useSelector(getIsUserOwner); + const isAuthenticated = useSelector(getAuthenticated); + const sketchOwner = useSelector(getSketchOwner); + + const syncFileContent = () => { + const file = getContent(); + dispatch(updateFileContent(file.id, file.content)); + }; + + useKeyDownHandlers({ + 'ctrl-s': (e) => { + e.preventDefault(); + e.stopPropagation(); + if (isUserOwner || (isAuthenticated && !sketchOwner)) { + dispatch(saveProject(getContent())); + } else if (isAuthenticated) { + dispatch(cloneProject()); + } else { + dispatch(showErrorModal('forceAuthentication')); + } + }, + 'ctrl-shift-enter': (e) => { + e.preventDefault(); + e.stopPropagation(); + dispatch(stopSketch()); + }, + 'ctrl-enter': (e) => { + e.preventDefault(); + e.stopPropagation(); + syncFileContent(); + dispatch(startSketch()); + }, + 'ctrl-shift-1': (e) => { + e.preventDefault(); + dispatch(setAllAccessibleOutput(true)); + }, + 'ctrl-shift-2': (e) => { + e.preventDefault(); + dispatch(setAllAccessibleOutput(false)); + }, + 'ctrl-b': (e) => { + e.preventDefault(); + dispatch( + // TODO: create actions 'toggleConsole', 'toggleSidebar', etc. + sidebarIsExpanded ? collapseSidebar() : expandSidebar() + ); + }, + 'ctrl-`': (e) => { + e.preventDefault(); + dispatch(consoleIsExpanded ? collapseConsole() : expandConsole()); + } + }); +}; + +const IDEKeyHandlers = ({ getContent }) => { + useIDEKeyHandlers({ getContent }); + return null; +}; + +// Most actions can be accessed via redux, but those involving the cmController +// must be provided via props. +IDEKeyHandlers.propTypes = { + getContent: PropTypes.func.isRequired +}; + +export default IDEKeyHandlers; diff --git a/client/modules/IDE/components/Modal.jsx b/client/modules/IDE/components/Modal.jsx index cdbd217327..9b9cd2d614 100644 --- a/client/modules/IDE/components/Modal.jsx +++ b/client/modules/IDE/components/Modal.jsx @@ -2,6 +2,7 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React, { useEffect, useRef } from 'react'; import ExitIcon from '../../../images/exit.svg'; +import useKeyDownHandlers from '../hooks/useKeyDownHandlers'; // Common logic from NewFolderModal, NewFileModal, UploadFileModal @@ -30,6 +31,8 @@ const Modal = ({ }; }, []); + useKeyDownHandlers({ escape: onClose }); + return (
diff --git a/client/modules/IDE/hooks/useKeyDownHandlers.js b/client/modules/IDE/hooks/useKeyDownHandlers.js new file mode 100644 index 0000000000..e943f270ea --- /dev/null +++ b/client/modules/IDE/hooks/useKeyDownHandlers.js @@ -0,0 +1,55 @@ +import mapKeys from 'lodash/mapKeys'; +import { useCallback, useEffect, useRef } from 'react'; + +/** + * Attaches keydown handlers to the global document. + * + * Handles Mac/PC switching of Ctrl to Cmd. + * + * @param {Record void>} keyHandlers - an object + * which maps from the key to its event handler. The object keys are a combination + * of the key and prefixes `ctrl-` `shift-` (ie. 'ctrl-f', 'ctrl-shift-f') + * and the values are the function to call when that specific key is pressed. + */ +export default function useKeyDownHandlers(keyHandlers) { + /** + * Instead of memoizing the handlers, use a ref and call the current + * handler at the time of the event. + */ + const handlers = useRef(keyHandlers); + + useEffect(() => { + handlers.current = mapKeys(keyHandlers, (value, key) => key.toLowerCase()); + }, [keyHandlers]); + + /** + * Will call all matching handlers, starting with the most specific: 'ctrl-shift-f' => 'ctrl-f' => 'f'. + * Can use e.stopPropagation() to prevent subsequent handlers. + * @type {(function(KeyboardEvent): void)} + */ + const handleEvent = useCallback((e) => { + const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1; + const isCtrl = isMac ? e.metaKey && this.isMac : e.ctrlKey; + if (e.shiftKey && isCtrl) { + handlers.current[`ctrl-shift-${e.key.toLowerCase()}`]?.(e); + } + if (isCtrl) { + handlers.current[`ctrl-${e.key.toLowerCase()}`]?.(e); + } + handlers.current[e.key.toLowerCase()]?.(e); + }, []); + + useEffect(() => { + document.addEventListener('keydown', handleEvent); + + return () => document.removeEventListener('keydown', handleEvent); + }, [handleEvent]); +} + +/** + * Component version can be used in class components where hooks can't be used. + */ +export const DocumentKeyDown = ({ handlers }) => { + useKeyDownHandlers(handlers); + return null; +}; diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index 05870c8de9..5469b183de 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -7,6 +7,7 @@ import { withTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet'; import SplitPane from 'react-split-pane'; import Editor from '../components/Editor'; +import IDEKeyHandlers from '../components/IDEKeyHandlers'; import Sidebar from '../components/Sidebar'; import PreviewFrame from '../components/PreviewFrame'; import Toolbar from '../components/Toolbar'; @@ -63,7 +64,6 @@ function warnIfUnsavedChanges(props, nextLocation) { class IDEView extends React.Component { constructor(props) { super(props); - this.handleGlobalKeydown = this.handleGlobalKeydown.bind(this); this.state = { consoleSize: props.ide.consoleIsExpanded ? 150 : 29, @@ -84,9 +84,6 @@ class IDEView extends React.Component { } } - this.isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1; - document.addEventListener('keydown', this.handleGlobalKeydown, false); - this.props.router.setRouteLeaveHook( this.props.route, this.handleUnsavedChanges @@ -149,88 +146,9 @@ class IDEView extends React.Component { } } componentWillUnmount() { - document.removeEventListener('keydown', this.handleGlobalKeydown, false); clearTimeout(this.autosaveInterval); this.autosaveInterval = null; } - handleGlobalKeydown(e) { - // 83 === s - if ( - e.keyCode === 83 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) - ) { - e.preventDefault(); - e.stopPropagation(); - if ( - this.props.isUserOwner || - (this.props.user.authenticated && !this.props.project.owner) - ) { - this.props.saveProject(this.cmController.getContent()); - } else if (this.props.user.authenticated) { - this.props.cloneProject(); - } else { - this.props.showErrorModal('forceAuthentication'); - } - // 13 === enter - } else if ( - e.keyCode === 13 && - e.shiftKey && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) - ) { - e.preventDefault(); - e.stopPropagation(); - this.props.stopSketch(); - } else if ( - e.keyCode === 13 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) - ) { - e.preventDefault(); - e.stopPropagation(); - this.syncFileContent(); - this.props.startSketch(); - // 50 === 2 - } else if ( - e.keyCode === 50 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && - e.shiftKey - ) { - e.preventDefault(); - this.props.setAllAccessibleOutput(false); - // 49 === 1 - } else if ( - e.keyCode === 49 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && - e.shiftKey - ) { - e.preventDefault(); - this.props.setAllAccessibleOutput(true); - } else if ( - e.keyCode === 66 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) - ) { - e.preventDefault(); - if (!this.props.ide.sidebarIsExpanded) { - this.props.expandSidebar(); - } else { - this.props.collapseSidebar(); - } - } else if (e.keyCode === 192 && e.ctrlKey) { - e.preventDefault(); - if (this.props.ide.consoleIsExpanded) { - this.props.collapseConsole(); - } else { - this.props.expandConsole(); - } - } else if (e.keyCode === 27) { - if (this.props.ide.newFolderModalVisible) { - this.props.closeNewFolderModal(); - } else if (this.props.ide.uploadFileModalVisible) { - this.props.closeUploadFileModal(); - } else if (this.props.ide.modalIsVisible) { - this.props.closeNewFileModal(); - } - } - } handleUnsavedChanges = (nextLocation) => warnIfUnsavedChanges(this.props, nextLocation); @@ -255,6 +173,7 @@ class IDEView extends React.Component { {getTitle(this.props)} + this.cmController.getContent()} /> {this.props.toast.isVisible && }
diff --git a/client/modules/IDE/components/IDEKeyHandlers.jsx b/client/modules/IDE/components/IDEKeyHandlers.jsx new file mode 100644 index 0000000000..6578753f88 --- /dev/null +++ b/client/modules/IDE/components/IDEKeyHandlers.jsx @@ -0,0 +1,93 @@ +import PropTypes from 'prop-types'; +import { useDispatch, useSelector } from 'react-redux'; +import { updateFileContent } from '../actions/files'; +import { + collapseConsole, + collapseSidebar, + expandConsole, + expandSidebar, + showErrorModal, + startSketch, + stopSketch +} from '../actions/ide'; +import { setAllAccessibleOutput } from '../actions/preferences'; +import { cloneProject, saveProject } from '../actions/project'; +import useKeyDownHandlers from '../hooks/useKeyDownHandlers'; +import { + getAuthenticated, + getIsUserOwner, + getSketchOwner +} from '../selectors/users'; + +export const useIDEKeyHandlers = ({ getContent }) => { + const dispatch = useDispatch(); + + const sidebarIsExpanded = useSelector((state) => state.ide.sidebarIsExpanded); + const consoleIsExpanded = useSelector((state) => state.ide.consoleIsExpanded); + + const isUserOwner = useSelector(getIsUserOwner); + const isAuthenticated = useSelector(getAuthenticated); + const sketchOwner = useSelector(getSketchOwner); + + const syncFileContent = () => { + const file = getContent(); + dispatch(updateFileContent(file.id, file.content)); + }; + + useKeyDownHandlers({ + 'ctrl-s': (e) => { + e.preventDefault(); + e.stopPropagation(); + if (isUserOwner || (isAuthenticated && !sketchOwner)) { + dispatch(saveProject(getContent())); + } else if (isAuthenticated) { + dispatch(cloneProject()); + } else { + dispatch(showErrorModal('forceAuthentication')); + } + }, + 'ctrl-shift-enter': (e) => { + e.preventDefault(); + e.stopPropagation(); + dispatch(stopSketch()); + }, + 'ctrl-enter': (e) => { + e.preventDefault(); + e.stopPropagation(); + syncFileContent(); + dispatch(startSketch()); + }, + 'ctrl-shift-1': (e) => { + e.preventDefault(); + dispatch(setAllAccessibleOutput(true)); + }, + 'ctrl-shift-2': (e) => { + e.preventDefault(); + dispatch(setAllAccessibleOutput(false)); + }, + 'ctrl-b': (e) => { + e.preventDefault(); + dispatch( + // TODO: create actions 'toggleConsole', 'toggleSidebar', etc. + sidebarIsExpanded ? collapseSidebar() : expandSidebar() + ); + }, + 'ctrl-`': (e) => { + e.preventDefault(); + dispatch(consoleIsExpanded ? collapseConsole() : expandConsole()); + } + }); +}; + +const IDEKeyHandlers = ({ getContent }) => { + useIDEKeyHandlers({ getContent }); + return null; +}; + +// Most actions can be accessed via redux, but those involving the cmController +// must be provided via props. +IDEKeyHandlers.propTypes = { + getContent: PropTypes.func.isRequired +}; + +export default IDEKeyHandlers; diff --git a/client/modules/IDE/components/Modal.jsx b/client/modules/IDE/components/Modal.jsx index cdbd217327..9b9cd2d614 100644 --- a/client/modules/IDE/components/Modal.jsx +++ b/client/modules/IDE/components/Modal.jsx @@ -2,6 +2,7 @@ import classNames from 'classnames'; import PropTypes from 'prop-types'; import React, { useEffect, useRef } from 'react'; import ExitIcon from '../../../images/exit.svg'; +import useKeyDownHandlers from '../hooks/useKeyDownHandlers'; // Common logic from NewFolderModal, NewFileModal, UploadFileModal @@ -30,6 +31,8 @@ const Modal = ({ }; }, []); + useKeyDownHandlers({ escape: onClose }); + return (
diff --git a/client/modules/IDE/hooks/useKeyDownHandlers.js b/client/modules/IDE/hooks/useKeyDownHandlers.js new file mode 100644 index 0000000000..e943f270ea --- /dev/null +++ b/client/modules/IDE/hooks/useKeyDownHandlers.js @@ -0,0 +1,55 @@ +import mapKeys from 'lodash/mapKeys'; +import { useCallback, useEffect, useRef } from 'react'; + +/** + * Attaches keydown handlers to the global document. + * + * Handles Mac/PC switching of Ctrl to Cmd. + * + * @param {Record void>} keyHandlers - an object + * which maps from the key to its event handler. The object keys are a combination + * of the key and prefixes `ctrl-` `shift-` (ie. 'ctrl-f', 'ctrl-shift-f') + * and the values are the function to call when that specific key is pressed. + */ +export default function useKeyDownHandlers(keyHandlers) { + /** + * Instead of memoizing the handlers, use a ref and call the current + * handler at the time of the event. + */ + const handlers = useRef(keyHandlers); + + useEffect(() => { + handlers.current = mapKeys(keyHandlers, (value, key) => key.toLowerCase()); + }, [keyHandlers]); + + /** + * Will call all matching handlers, starting with the most specific: 'ctrl-shift-f' => 'ctrl-f' => 'f'. + * Can use e.stopPropagation() to prevent subsequent handlers. + * @type {(function(KeyboardEvent): void)} + */ + const handleEvent = useCallback((e) => { + const isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1; + const isCtrl = isMac ? e.metaKey && this.isMac : e.ctrlKey; + if (e.shiftKey && isCtrl) { + handlers.current[`ctrl-shift-${e.key.toLowerCase()}`]?.(e); + } + if (isCtrl) { + handlers.current[`ctrl-${e.key.toLowerCase()}`]?.(e); + } + handlers.current[e.key.toLowerCase()]?.(e); + }, []); + + useEffect(() => { + document.addEventListener('keydown', handleEvent); + + return () => document.removeEventListener('keydown', handleEvent); + }, [handleEvent]); +} + +/** + * Component version can be used in class components where hooks can't be used. + */ +export const DocumentKeyDown = ({ handlers }) => { + useKeyDownHandlers(handlers); + return null; +}; diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index 3c323fe88c..7a854f13d9 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -7,6 +7,7 @@ import { withTranslation } from 'react-i18next'; import { Helmet } from 'react-helmet'; import SplitPane from 'react-split-pane'; import Editor from '../components/Editor'; +import IDEKeyHandlers from '../components/IDEKeyHandlers'; import Sidebar from '../components/Sidebar'; import PreviewFrame from '../components/PreviewFrame'; import Toolbar from '../components/Toolbar'; @@ -62,7 +63,6 @@ function warnIfUnsavedChanges(props, nextLocation) { class IDEView extends React.Component { constructor(props) { super(props); - this.handleGlobalKeydown = this.handleGlobalKeydown.bind(this); this.state = { consoleSize: props.ide.consoleIsExpanded ? 150 : 29, @@ -83,9 +83,6 @@ class IDEView extends React.Component { } } - this.isMac = navigator.userAgent.toLowerCase().indexOf('mac') !== -1; - document.addEventListener('keydown', this.handleGlobalKeydown, false); - this.props.router.setRouteLeaveHook( this.props.route, this.handleUnsavedChanges @@ -148,88 +145,9 @@ class IDEView extends React.Component { } } componentWillUnmount() { - document.removeEventListener('keydown', this.handleGlobalKeydown, false); clearTimeout(this.autosaveInterval); this.autosaveInterval = null; } - handleGlobalKeydown(e) { - // 83 === s - if ( - e.keyCode === 83 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) - ) { - e.preventDefault(); - e.stopPropagation(); - if ( - this.props.isUserOwner || - (this.props.user.authenticated && !this.props.project.owner) - ) { - this.props.saveProject(this.cmController.getContent()); - } else if (this.props.user.authenticated) { - this.props.cloneProject(); - } else { - this.props.showErrorModal('forceAuthentication'); - } - // 13 === enter - } else if ( - e.keyCode === 13 && - e.shiftKey && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) - ) { - e.preventDefault(); - e.stopPropagation(); - this.props.stopSketch(); - } else if ( - e.keyCode === 13 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) - ) { - e.preventDefault(); - e.stopPropagation(); - this.syncFileContent(); - this.props.startSketch(); - // 50 === 2 - } else if ( - e.keyCode === 50 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && - e.shiftKey - ) { - e.preventDefault(); - this.props.setAllAccessibleOutput(false); - // 49 === 1 - } else if ( - e.keyCode === 49 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) && - e.shiftKey - ) { - e.preventDefault(); - this.props.setAllAccessibleOutput(true); - } else if ( - e.keyCode === 66 && - ((e.metaKey && this.isMac) || (e.ctrlKey && !this.isMac)) - ) { - e.preventDefault(); - if (!this.props.ide.sidebarIsExpanded) { - this.props.expandSidebar(); - } else { - this.props.collapseSidebar(); - } - } else if (e.keyCode === 192 && e.ctrlKey) { - e.preventDefault(); - if (this.props.ide.consoleIsExpanded) { - this.props.collapseConsole(); - } else { - this.props.expandConsole(); - } - } else if (e.keyCode === 27) { - if (this.props.ide.newFolderModalVisible) { - this.props.closeNewFolderModal(); - } else if (this.props.ide.uploadFileModalVisible) { - this.props.closeUploadFileModal(); - } else if (this.props.ide.modalIsVisible) { - this.props.closeNewFileModal(); - } - } - } handleUnsavedChanges = (nextLocation) => warnIfUnsavedChanges(this.props, nextLocation); @@ -254,6 +172,7 @@ class IDEView extends React.Component { {getTitle(this.props)} + this.cmController.getContent()} />
); -}; - -ShareModal.propTypes = { - projectId: PropTypes.string.isRequired, - ownerUsername: PropTypes.string.isRequired, - projectName: PropTypes.string.isRequired -}; - -export default ShareModal; +} diff --git a/client/modules/IDE/components/Sidebar.jsx b/client/modules/IDE/components/Sidebar.jsx index 1553c40361..0d7c7fa2b7 100644 --- a/client/modules/IDE/components/Sidebar.jsx +++ b/client/modules/IDE/components/Sidebar.jsx @@ -1,178 +1,135 @@ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useRef, useState } from 'react'; import classNames from 'classnames'; -import { withTranslation } from 'react-i18next'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { + closeProjectOptions, + newFile, + newFolder, + openProjectOptions, + openUploadFileModal +} from '../actions/ide'; +import { selectRootFile } from '../selectors/files'; +import { getSketchOwner } from '../selectors/users'; import ConnectedFileNode from './FileNode'; import DownArrowIcon from '../../../images/down-filled-triangle.svg'; -class Sidebar extends React.Component { - constructor(props) { - super(props); - this.resetSelectedFile = this.resetSelectedFile.bind(this); - this.toggleProjectOptions = this.toggleProjectOptions.bind(this); - this.onBlurComponent = this.onBlurComponent.bind(this); - this.onFocusComponent = this.onFocusComponent.bind(this); - - this.state = { - isFocused: false - }; - } - - onBlurComponent() { - this.setState({ isFocused: false }); +export default function Sidebar() { + const { t } = useTranslation(); + + const dispatch = useDispatch(); + + const [isFocused, setIsFocused] = useState(false); + + const onBlurComponent = () => { + setIsFocused(false); setTimeout(() => { - if (!this.state.isFocused) { - this.props.closeProjectOptions(); + if (!isFocused) { + dispatch(closeProjectOptions()); } }, 200); - } + }; + + const onFocusComponent = () => setIsFocused(true); + + const rootFile = useSelector(selectRootFile); - onFocusComponent() { - this.setState({ isFocused: true }); - } + const projectOptionsVisible = useSelector( + (state) => state.ide.projectOptionsVisible + ); - resetSelectedFile() { - this.props.setSelectedFile(this.props.files[1].id); - } + const toggleRef = useRef(null); - toggleProjectOptions(e) { + const toggleProjectOptions = (e) => { e.preventDefault(); - if (this.props.projectOptionsVisible) { - this.props.closeProjectOptions(); + if (projectOptionsVisible) { + dispatch(closeProjectOptions()); } else { - this.sidebarOptions.focus(); - this.props.openProjectOptions(); + toggleRef.current?.focus(); + dispatch(openProjectOptions()); } - } - - userCanEditProject() { - let canEdit; - if (!this.props.owner) { - canEdit = true; - } else if ( - this.props.user.authenticated && - this.props.owner.id === this.props.user.id - ) { - canEdit = true; - } else { - canEdit = false; - } - return canEdit; - } - - render() { - const canEditProject = this.userCanEditProject(); - const sidebarClass = classNames({ - sidebar: true, - 'sidebar--contracted': !this.props.isExpanded, - 'sidebar--project-options': this.props.projectOptionsVisible, - 'sidebar--cant-edit': !canEditProject - }); - const rootFile = this.props.files.filter((file) => file.name === 'root')[0]; - - return ( -
-
-

- {this.props.t('Sidebar.Title')} -

-
- -
    -
  • - -
  • + }; + + const user = useSelector((state) => state.user); + const owner = useSelector(getSketchOwner); + + const canEditProject = !owner || (user.authenticated && owner.id === user.id); + + const isExpanded = useSelector((state) => state.ide.sidebarIsExpanded); + + const sidebarClass = classNames({ + sidebar: true, + 'sidebar--contracted': !isExpanded, + 'sidebar--project-options': projectOptionsVisible, + 'sidebar--cant-edit': !canEditProject + }); + + return ( +
    +
    +

    + {t('Sidebar.Title')} +

    +
    + +
      +
    • + +
    • +
    • + +
    • + {user.authenticated && (
    • - {this.props.user.authenticated && ( -
    • - -
    • - )} -
    -
    -
    - -
    - ); - } + )} +
+
+
+ +
+ ); } - -Sidebar.propTypes = { - files: PropTypes.arrayOf( - PropTypes.shape({ - name: PropTypes.string.isRequired, - id: PropTypes.string.isRequired - }) - ).isRequired, - setSelectedFile: PropTypes.func.isRequired, - isExpanded: PropTypes.bool.isRequired, - projectOptionsVisible: PropTypes.bool.isRequired, - newFile: PropTypes.func.isRequired, - openProjectOptions: PropTypes.func.isRequired, - closeProjectOptions: PropTypes.func.isRequired, - newFolder: PropTypes.func.isRequired, - openUploadFileModal: PropTypes.func.isRequired, - owner: PropTypes.shape({ - id: PropTypes.string - }), - user: PropTypes.shape({ - id: PropTypes.string, - authenticated: PropTypes.bool.isRequired - }).isRequired, - t: PropTypes.func.isRequired -}; - -Sidebar.defaultProps = { - owner: undefined -}; - -export default withTranslation()(Sidebar); diff --git a/client/modules/IDE/pages/IDEOverlays.jsx b/client/modules/IDE/pages/IDEOverlays.jsx new file mode 100644 index 0000000000..7f1b4ed077 --- /dev/null +++ b/client/modules/IDE/pages/IDEOverlays.jsx @@ -0,0 +1,133 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { withRouter } from 'react-router'; +import Overlay from '../../App/components/Overlay'; +import { + closeKeyboardShortcutModal, + closePreferences, + closeShareModal, + hideErrorModal +} from '../actions/ide'; +import About from '../components/About'; +import AddToCollectionList from '../components/AddToCollectionList'; +import ErrorModal from '../components/ErrorModal'; +import Feedback from '../components/Feedback'; +import KeyboardShortcutModal from '../components/KeyboardShortcutModal'; +import NewFileModal from '../components/NewFileModal'; +import NewFolderModal from '../components/NewFolderModal'; +import Preferences from '../components/Preferences'; +import { CollectionSearchbar } from '../components/Searchbar'; +import ShareModal from '../components/ShareModal'; +import UploadFileModal from '../components/UploadFileModal'; + +function IDEOverlays({ location, params }) { + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const { + modalIsVisible, + newFolderModalVisible, + uploadFileModalVisible, + preferencesIsVisible, + shareModalVisible, + keyboardShortcutVisible, + errorType, + previousPath + } = useSelector((state) => state.ide); + + return ( + <> + {preferencesIsVisible && ( + dispatch(closePreferences())} + > + + + )} + {location.pathname === '/about' && ( + + + + )} + {location.pathname === '/feedback' && ( + + + + )} + {location.pathname.match(/add-to-collection$/) && ( + } + isFixedHeight + > + + + )} + {shareModalVisible && ( + dispatch(closeShareModal())} + > + + + )} + {keyboardShortcutVisible && ( + dispatch(closeKeyboardShortcutModal())} + > + + + )} + {errorType && ( + dispatch(hideErrorModal())} + > + dispatch(hideErrorModal())} + /> + + )} + {modalIsVisible && } + {newFolderModalVisible && } + {uploadFileModalVisible && } + + ); +} + +// TODO: use `useLocation` hook after updating react-router + +IDEOverlays.propTypes = { + location: PropTypes.shape({ + pathname: PropTypes.string + }).isRequired, + params: PropTypes.shape({ + project_id: PropTypes.string, + username: PropTypes.string, + reset_password_token: PropTypes.string + }).isRequired +}; + +export default withRouter(IDEOverlays); diff --git a/client/modules/IDE/pages/IDEView.jsx b/client/modules/IDE/pages/IDEView.jsx index 7a854f13d9..13ed825a4c 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -1,6 +1,5 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { withRouter } from 'react-router'; import { withTranslation } from 'react-i18next'; @@ -11,31 +10,20 @@ import IDEKeyHandlers from '../components/IDEKeyHandlers'; import Sidebar from '../components/Sidebar'; import PreviewFrame from '../components/PreviewFrame'; import Toolbar from '../components/Toolbar'; -import Preferences from '../components/Preferences/index'; -import NewFileModal from '../components/NewFileModal'; -import NewFolderModal from '../components/NewFolderModal'; -import UploadFileModal from '../components/UploadFileModal'; -import ShareModal from '../components/ShareModal'; -import KeyboardShortcutModal from '../components/KeyboardShortcutModal'; -import ErrorModal from '../components/ErrorModal'; import Nav from '../../../components/Nav'; import Console from '../components/Console'; import Toast from '../components/Toast'; -import * as FileActions from '../actions/files'; -import * as IDEActions from '../actions/ide'; -import * as ProjectActions from '../actions/project'; -import * as EditorAccessibilityActions from '../actions/editorAccessibility'; -import * as PreferencesActions from '../actions/preferences'; -import * as UserActions from '../../User/actions'; -import * as ConsoleActions from '../actions/console'; -import { getHTMLFile } from '../reducers/files'; -import Overlay from '../../App/components/Overlay'; -import About from '../components/About'; -import AddToCollectionList from '../components/AddToCollectionList'; -import Feedback from '../components/Feedback'; -import { CollectionSearchbar } from '../components/Searchbar'; +import { updateFileContent } from '../actions/files'; +import { setPreviousPath, stopSketch } from '../actions/ide'; +import { + autosaveProject, + clearPersistedState, + getProject +} from '../actions/project'; +import { selectActiveFile } from '../selectors/files'; import { getIsUserOwner } from '../selectors/users'; import RootPage from '../../../components/RootPage'; +import IDEOverlays from './IDEOverlays'; function getTitle(props) { const { id } = props.project; @@ -182,36 +170,6 @@ class IDEView extends React.Component { syncFileContent={this.syncFileContent} key={this.props.project.id} /> - {this.props.ide.preferencesIsVisible && ( - - - - )}
- +
- {this.props.ide.modalIsVisible && } - {this.props.ide.newFolderModalVisible && } - {this.props.ide.uploadFileModalVisible && } - {this.props.location.pathname === '/about' && ( - - - - )} - {this.props.location.pathname === '/feedback' && ( - - - - )} - {this.props.location.pathname.match(/add-to-collection$/) && ( - } - isFixedHeight - > - - - )} - {this.props.ide.shareModalVisible && ( - - - - )} - {this.props.ide.keyboardShortcutVisible && ( - - - - )} - {this.props.ide.errorType && ( - - - - )} + ); } @@ -387,21 +261,9 @@ IDEView.propTypes = { username: PropTypes.string }).isRequired, ide: PropTypes.shape({ - errorType: PropTypes.string, - keyboardShortcutVisible: PropTypes.bool.isRequired, - shareModalVisible: PropTypes.bool.isRequired, - shareModalProjectId: PropTypes.string.isRequired, - shareModalProjectName: PropTypes.string.isRequired, - shareModalProjectUsername: PropTypes.string.isRequired, - previousPath: PropTypes.string.isRequired, - previewIsRefreshing: PropTypes.bool.isRequired, isPlaying: PropTypes.bool.isRequired, isAccessibleOutputPlaying: PropTypes.bool.isRequired, projectOptionsVisible: PropTypes.bool.isRequired, - preferencesIsVisible: PropTypes.bool.isRequired, - modalIsVisible: PropTypes.bool.isRequired, - uploadFileModalVisible: PropTypes.bool.isRequired, - newFolderModalVisible: PropTypes.bool.isRequired, justOpenedProject: PropTypes.bool.isRequired, sidebarIsExpanded: PropTypes.bool.isRequired, consoleIsExpanded: PropTypes.bool.isRequired, @@ -419,101 +281,45 @@ IDEView.propTypes = { }).isRequired, preferences: PropTypes.shape({ autosave: PropTypes.bool.isRequired, - fontSize: PropTypes.number.isRequired, - linewrap: PropTypes.bool.isRequired, - lineNumbers: PropTypes.bool.isRequired, - lintWarning: PropTypes.bool.isRequired, textOutput: PropTypes.bool.isRequired, - gridOutput: PropTypes.bool.isRequired, - theme: PropTypes.string.isRequired, - autorefresh: PropTypes.bool.isRequired, - language: PropTypes.string.isRequired, - autocloseBracketsQuotes: PropTypes.bool.isRequired + gridOutput: PropTypes.bool.isRequired }).isRequired, - closePreferences: PropTypes.func.isRequired, - setAutocloseBracketsQuotes: PropTypes.func.isRequired, - setFontSize: PropTypes.func.isRequired, - setAutosave: PropTypes.func.isRequired, - setLineNumbers: PropTypes.func.isRequired, - setLinewrap: PropTypes.func.isRequired, - setLintWarning: PropTypes.func.isRequired, - setTextOutput: PropTypes.func.isRequired, - setGridOutput: PropTypes.func.isRequired, - files: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - content: PropTypes.string.isRequired - }) - ).isRequired, selectedFile: PropTypes.shape({ id: PropTypes.string.isRequired, content: PropTypes.string.isRequired, name: PropTypes.string.isRequired }).isRequired, - setSelectedFile: PropTypes.func.isRequired, - htmlFile: PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - content: PropTypes.string.isRequired - }).isRequired, - newFile: PropTypes.func.isRequired, - deleteFile: PropTypes.func.isRequired, - updateFileName: PropTypes.func.isRequired, updateFileContent: PropTypes.func.isRequired, - openProjectOptions: PropTypes.func.isRequired, - closeProjectOptions: PropTypes.func.isRequired, - newFolder: PropTypes.func.isRequired, - closeShareModal: PropTypes.func.isRequired, - closeKeyboardShortcutModal: PropTypes.func.isRequired, autosaveProject: PropTypes.func.isRequired, router: PropTypes.shape({ setRouteLeaveHook: PropTypes.func }).isRequired, route: PropTypes.oneOfType([PropTypes.object, PropTypes.element]).isRequired, - setTheme: PropTypes.func.isRequired, setPreviousPath: PropTypes.func.isRequired, - hideErrorModal: PropTypes.func.isRequired, clearPersistedState: PropTypes.func.isRequired, - openUploadFileModal: PropTypes.func.isRequired, - closeUploadFileModal: PropTypes.func.isRequired, t: PropTypes.func.isRequired, isUserOwner: PropTypes.bool.isRequired }; function mapStateToProps(state) { return { - files: state.files, - selectedFile: - state.files.find((file) => file.isSelectedFile) || - state.files.find((file) => file.name === 'sketch.js') || - state.files.find((file) => file.name !== 'root'), - htmlFile: getHTMLFile(state.files), + selectedFile: selectActiveFile(state), ide: state.ide, preferences: state.preferences, - editorAccessibility: state.editorAccessibility, user: state.user, project: state.project, - console: state.console, isUserOwner: getIsUserOwner(state) }; } -function mapDispatchToProps(dispatch) { - return bindActionCreators( - Object.assign( - {}, - EditorAccessibilityActions, - FileActions, - ProjectActions, - IDEActions, - PreferencesActions, - UserActions, - ConsoleActions - ), - dispatch - ); -} +const mapDispatchToProps = { + autosaveProject, + clearPersistedState, + getProject, + setPreviousPath, + stopSketch, + updateFileContent +}; export default withTranslation()( withRouter(connect(mapStateToProps, mapDispatchToProps)(IDEView)) diff --git a/client/modules/IDE/pages/MobileIDEView.jsx b/client/modules/IDE/pages/MobileIDEView.jsx index c5b736483e..5b41cc7399 100644 --- a/client/modules/IDE/pages/MobileIDEView.jsx +++ b/client/modules/IDE/pages/MobileIDEView.jsx @@ -7,8 +7,7 @@ import { useTranslation } from 'react-i18next'; import styled from 'styled-components'; // Imports to be Refactored -import { bindActionCreators } from 'redux'; - +import * as UserActions from '../../User/actions'; import * as IDEActions from '../actions/ide'; import * as ProjectActions from '../actions/project'; import * as ConsoleActions from '../actions/console'; @@ -42,6 +41,7 @@ import { remSize } from '../../../theme'; import ActionStrip from '../../../components/mobile/ActionStrip'; import useAsModal from '../../../components/useAsModal'; import Dropdown from '../../../components/Dropdown'; +import { selectActiveFile } from '../selectors/files'; import { getIsUserOwner } from '../selectors/users'; import { useEffectWithComparison } from '../hooks/custom-hooks'; @@ -321,23 +321,6 @@ const MobileIDEView = (props) => { ); }; -const handleGlobalKeydownProps = { - expandConsole: PropTypes.func.isRequired, - collapseConsole: PropTypes.func.isRequired, - expandSidebar: PropTypes.func.isRequired, - collapseSidebar: PropTypes.func.isRequired, - - setAllAccessibleOutput: PropTypes.func.isRequired, - saveProject: PropTypes.func.isRequired, - cloneProject: PropTypes.func.isRequired, - showErrorModal: PropTypes.func.isRequired, - - closeNewFolderModal: PropTypes.func.isRequired, - closeUploadFileModal: PropTypes.func.isRequired, - closeNewFileModal: PropTypes.func.isRequired, - isUserOwner: PropTypes.bool.isRequired -}; - MobileIDEView.propTypes = { ide: PropTypes.shape({ consoleIsExpanded: PropTypes.bool.isRequired @@ -386,20 +369,20 @@ MobileIDEView.propTypes = { }).isRequired, startSketch: PropTypes.func.isRequired, + stopSketch: PropTypes.func.isRequired, unsavedChanges: PropTypes.bool.isRequired, autosaveProject: PropTypes.func.isRequired, isUserOwner: PropTypes.bool.isRequired, - ...handleGlobalKeydownProps + expandConsole: PropTypes.func.isRequired, + collapseConsole: PropTypes.func.isRequired, + saveProject: PropTypes.func.isRequired }; function mapStateToProps(state) { return { - selectedFile: - state.files.find((file) => file.isSelectedFile) || - state.files.find((file) => file.name === 'sketch.js') || - state.files.find((file) => file.name !== 'root'), + selectedFile: selectActiveFile(state), ide: state.ide, files: state.files, unsavedChanges: state.ide.unsavedChanges, @@ -411,17 +394,14 @@ function mapStateToProps(state) { }; } -const mapDispatchToProps = (dispatch) => - bindActionCreators( - { - ...ProjectActions, - ...IDEActions, - ...ConsoleActions, - ...PreferencesActions, - ...EditorAccessibilityActions - }, - dispatch - ); +const mapDispatchToProps = { + ...ProjectActions, + ...IDEActions, + ...ConsoleActions, + ...PreferencesActions, + ...EditorAccessibilityActions, + ...UserActions +}; export default withRouter( connect(mapStateToProps, mapDispatchToProps)(MobileIDEView) diff --git a/client/modules/IDE/selectors/files.js b/client/modules/IDE/selectors/files.js new file mode 100644 index 0000000000..e2044b6d89 --- /dev/null +++ b/client/modules/IDE/selectors/files.js @@ -0,0 +1,15 @@ +import { createSelector } from 'reselect'; + +const selectFiles = (state) => state.files; + +export const selectRootFile = createSelector(selectFiles, (files) => + files.find((file) => file.name === 'root') +); + +export const selectActiveFile = createSelector( + selectFiles, + (files) => + files.find((file) => file.isSelectedFile) || + files.find((file) => file.name === 'sketch.js') || + files.find((file) => file.name !== 'root') +); diff --git a/client/modules/Mobile/MobileSketchView.jsx b/client/modules/Mobile/MobileSketchView.jsx index dc69550f6f..3cbb8ebb78 100644 --- a/client/modules/Mobile/MobileSketchView.jsx +++ b/client/modules/Mobile/MobileSketchView.jsx @@ -16,6 +16,7 @@ import { getHTMLFile } from '../IDE/reducers/files'; import { ExitIcon } from '../../common/icons'; import Footer from '../../components/mobile/Footer'; +import { selectActiveFile } from '../IDE/selectors/files'; import Content from './MobileViewContent'; const MobileSketchView = () => { @@ -23,12 +24,7 @@ const MobileSketchView = () => { const htmlFile = useSelector((state) => getHTMLFile(state.files)); const projectName = useSelector((state) => state.project.name); - const selectedFile = useSelector( - (state) => - state.files.find((file) => file.isSelectedFile) || - state.files.find((file) => file.name === 'sketch.js') || - state.files.find((file) => file.name !== 'root') - ); + const selectedFile = useSelector(selectActiveFile); const { setTextOutput, From 72812e28162e0bf540a7f6644675b6a622f33806 Mon Sep 17 00:00:00 2001 From: Linda Paiste Date: Wed, 15 Mar 2023 00:11:36 -0500 Subject: [PATCH 7/8] Refactor tests to render inside react-redux context. --- .../Preferences/Preferences.unit.test.jsx | 245 +++++------------- client/test-utils.js | 11 +- 2 files changed, 74 insertions(+), 182 deletions(-) diff --git a/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx b/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx index 204b947794..205f97c408 100644 --- a/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx +++ b/client/modules/IDE/components/Preferences/Preferences.unit.test.jsx @@ -1,60 +1,22 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; -import { fireEvent, render, screen } from '../../../../test-utils'; +import { fireEvent, reduxRender, screen } from '../../../../test-utils'; import Preferences from './index'; - -/* props to pass in: - * - this.props.fontSize : number - * - this.props.autosave : bool - * - this.props.autocloseBracketsQuotes : bool - * - this.props.linewrap : bool - * - this.props.lineNumbers : bool - * - this.props.theme : string - * - this.props.lintWarning : bool - * - this.props.textOutput : bool - * - this.props.gridOutput : bool - * - this.props.soundOutput : bool - * - t from internationalization - * - * - this.props.setFontSize(fontsize : number) - * - this.props.setAutosave(value : bool) - * - this.props.setAutocloseBracketsQuotes(value: bool) - * - this.props.setLinewrap(value : bool) - * - this.props.setLineNumbers(value : bool) - * - this.props.setTheme(color : string) -> can be {"light", "dark", "contrast"} - * - this.props.setLintWarning(value : bool) - * - this.props.setTextOutput(value : bool) - * - this.props.setGridOutput(value : bool) - * - this.props.setSoundOutput(value : bool) - * - - */ +import * as PreferencesActions from '../../actions/preferences'; describe('', () => { - let props = { - t: jest.fn(), - fontSize: 12, - autosave: false, - autocloseBracketsQuotes: false, - linewrap: false, - lineNumbers: false, - theme: 'contrast', - lintWarning: false, - textOutput: false, - gridOutput: false, - soundOutput: false, - setFontSize: jest.fn(), - setAutosave: jest.fn(), - setAutocloseBracketsQuotes: jest.fn(), - setLinewrap: jest.fn(), - setLineNumbers: jest.fn(), - setTheme: jest.fn(), - setLintWarning: jest.fn(), - setTextOutput: jest.fn(), - setGridOutput: jest.fn(), - setSoundOutput: jest.fn() - }; - - const subject = () => render(); + // For backwards compatibility, spy on each action creator to see when it was dispatched. + const props = Object.fromEntries( + Object.keys(PreferencesActions).map((name) => { + const spied = jest.spyOn(PreferencesActions, name); + return [name, spied]; + }) + ); + + const subject = (initialPreferences = {}) => + reduxRender(, { + initialState: { preferences: initialPreferences } + }); afterEach(() => { jest.clearAllMocks(); @@ -78,9 +40,7 @@ describe('', () => { it('increase font size by 2 when clicking plus button', () => { // render the component with font size set to 12 - act(() => { - subject(); - }); + const { store } = subject({ fontSize: 12 }); // get ahold of the button for increasing text size const fontPlusButton = screen.getByRole('button', { @@ -92,13 +52,16 @@ describe('', () => { fireEvent.click(fontPlusButton); }); - // expect that setFontSize has been called once with the argument 14 - expect(props.setFontSize).toHaveBeenCalledTimes(1); - expect(props.setFontSize.mock.calls[0][0]).toBe(14); + const fontSizeInput = screen.getByLabelText('Font Size'); + + // expect that the font size text input says "14" + expect(fontSizeInput.value).toBe('14'); + // expect that the stored value is a number 14 + expect(store.getState().preferences.fontSize).toBe(14); }); it('font size decrease button says decrease', () => { - // render the component with font size set to 12 + // render the component act(() => { subject(); }); @@ -114,9 +77,7 @@ describe('', () => { it('decrease font size by 2 when clicking minus button', () => { // render the component with font size set to 12 - act(() => { - subject(); - }); + const { store } = subject({ fontSize: 12 }); // get ahold of the button for decreasing text size const fontMinusButton = screen.getByRole('button', { @@ -128,19 +89,20 @@ describe('', () => { fireEvent.click(fontMinusButton); }); - // expect that setFontSize would have been called once with argument 10 - expect(props.setFontSize).toHaveBeenCalledTimes(1); - expect(props.setFontSize.mock.calls[0][0]).toBe(10); + const fontSizeInput = screen.getByLabelText('Font Size'); + + // expect that the font size text input says "10" + expect(fontSizeInput.value).toBe('10'); + // expect that the stored value is a number 10 + expect(store.getState().preferences.fontSize).toBe(10); }); it('font text field changes on manual text input', () => { // render the component with font size set to 12 - act(() => { - subject(); - }); + const { store } = subject({ fontSize: 12 }); // get ahold of the text field - const input = screen.getByRole('textbox', { name: /font size/i }); + const input = screen.getByLabelText('Font Size'); // change input to 24 act(() => { @@ -156,19 +118,16 @@ describe('', () => { ); }); - // expect that setFontSize was called once with 24 - expect(props.setFontSize).toHaveBeenCalledTimes(1); - expect(props.setFontSize.mock.calls[0][0]).toBe(24); + // expect that the font size is now 24 + expect(store.getState().preferences.fontSize).toBe(24); }); it('font size CAN NOT go over 36', () => { // render the component - act(() => { - subject(); - }); + const { store } = subject(); // get ahold of the text field - const input = screen.getByRole('textbox', { name: /font size/i }); + const input = screen.getByLabelText('Font Size'); act(() => { fireEvent.change(input, { target: { value: '100' } }); @@ -184,18 +143,15 @@ describe('', () => { ); }); - expect(props.setFontSize).toHaveBeenCalledTimes(1); - expect(props.setFontSize.mock.calls[0][0]).toBe(36); + expect(store.getState().preferences.fontSize).toBe(36); }); it('font size CAN NOT go under 8', () => { // render the component - act(() => { - subject(); - }); + const { store } = subject(); // get ahold of the text field - const input = screen.getByRole('textbox', { name: /font size/i }); + const input = screen.getByLabelText('Font Size'); act(() => { fireEvent.change(input, { target: { value: '0' } }); @@ -211,20 +167,17 @@ describe('', () => { ); }); - expect(props.setFontSize).toHaveBeenCalledTimes(1); - expect(props.setFontSize.mock.calls[0][0]).toBe(8); + expect(store.getState().preferences.fontSize).toBe(8); }); // this case is a bit synthetic because we wouldn't be able to type // h and then i, but it tests the same idea it('font size input field does NOT take non-integers', () => { // render the component - act(() => { - subject(); - }); + const { store } = subject({ fontSize: 12 }); // get ahold of the text field - const input = screen.getByRole('textbox', { name: /font size/i }); + const input = screen.getByLabelText('Font Size'); act(() => { fireEvent.change(input, { target: { value: 'hi' } }); @@ -243,18 +196,15 @@ describe('', () => { }); // it still sets the font size but it's still 12 - expect(props.setFontSize).toHaveBeenCalledTimes(1); - expect(props.setFontSize.mock.calls[0][0]).toBe(12); + expect(store.getState().preferences.fontSize).toBe(12); }); it('font size input field does NOT take "-"', () => { // render the component - act(() => { - subject(); - }); + const { store } = subject({ fontSize: 12 }); // get ahold of the text field - const input = screen.getByRole('textbox', { name: /font size/i }); + const input = screen.getByLabelText('Font Size'); act(() => { fireEvent.change(input, { target: { value: '-' } }); @@ -270,8 +220,7 @@ describe('', () => { ); }); - expect(props.setFontSize).toHaveBeenCalledTimes(1); - expect(props.setFontSize.mock.calls[0][0]).toBe(12); + expect(store.getState().preferences.fontSize).toBe(12); }); }); @@ -308,13 +257,9 @@ describe('', () => { describe('testing theme switching', () => { describe('dark mode', () => { - beforeAll(() => { - props.theme = 'dark'; - }); - it('switch to light', () => { act(() => { - subject(); + subject({ theme: 'dark' }); }); const themeRadioCurrent = screen.getByRole('radio', { @@ -335,13 +280,9 @@ describe('', () => { }); describe('light mode', () => { - beforeAll(() => { - props.theme = 'light'; - }); - it('switch to dark', () => { act(() => { - subject(); + subject({ theme: 'light' }); }); const themeRadioCurrent = screen.getByRole('radio', { @@ -362,7 +303,7 @@ describe('', () => { it('switch to contrast', () => { act(() => { - subject(); + subject({ theme: 'light' }); }); const themeRadioCurrent = screen.getByRole('radio', { name: /light theme on/i @@ -385,7 +326,7 @@ describe('', () => { describe('testing toggle UI elements on starting tab', () => { it('autosave toggle, starting at false', () => { act(() => { - subject(); + subject({ autosave: false }); }); // get ahold of the radio buttons for toggling autosave @@ -407,7 +348,7 @@ describe('', () => { it('autocloseBracketsQuotes toggle, starting at false', () => { // render the component with autocloseBracketsQuotes prop set to false act(() => { - subject(); + subject({ autocloseBracketsQuotes: false }); }); // get ahold of the radio buttons for toggling autocloseBracketsQuotes @@ -427,14 +368,10 @@ describe('', () => { }); describe('start autosave value at true', () => { - beforeAll(() => { - props.autosave = true; - }); - it('autosave toggle, starting at true', () => { // render the component with autosave prop set to true act(() => { - subject(); + subject({ autosave: true }); }); // get ahold of the radio buttons for toggling autosave @@ -455,13 +392,9 @@ describe('', () => { }); describe('start autoclose brackets value at true', () => { - beforeAll(() => { - props.autocloseBracketsQuotes = true; - }); - it('autocloseBracketsQuotes toggle, starting at true', () => { act(() => { - subject(); + subject({ autocloseBracketsQuotes: true }); }); // get ahold of the radio buttons for toggling autocloseBracketsQuotes @@ -482,14 +415,10 @@ describe('', () => { }); describe('start linewrap at false', () => { - beforeAll(() => { - props.linewrap = false; - }); - it('linewrap toggle, starting at false', () => { // render the component with linewrap prop set to false act(() => { - subject(); + subject({ linewrap: false }); }); // get ahold of the radio buttons for toggling autocloseBracketsQuotes @@ -510,14 +439,10 @@ describe('', () => { }); describe('start linewrap at true', () => { - beforeAll(() => { - props.linewrap = true; - }); - it('linewrap toggle, starting at true', () => { // render the component with linewrap prop set to false act(() => { - subject(); + subject({ linewrap: true }); }); // get ahold of the radio buttons for toggling autocloseBracketsQuotes @@ -573,14 +498,10 @@ describe('', () => { describe('testing toggle UI elements on accessibility tab', () => { describe('starting linenumbers at false', () => { - beforeAll(() => { - props.lineNumbers = false; - }); - it('lineNumbers toggle, starting at false', () => { // render the component with lineNumbers prop set to false act(() => { - subject(); + subject({ lineNumbers: false }); }); // switch tabs @@ -608,14 +529,10 @@ describe('', () => { }); describe('starting linenumbers at true', () => { - beforeAll(() => { - props.lineNumbers = true; - }); - it('lineNumbers toggle, starting at true', () => { // render the component with lineNumbers prop set to false act(() => { - subject(); + subject({ lineNumbers: true }); }); // switch tabs @@ -643,14 +560,10 @@ describe('', () => { }); describe('starting lintWarning at false', () => { - beforeAll(() => { - props.lintWarning = false; - }); - it('lintWarning toggle, starting at false', () => { // render the component with lintWarning prop set to false act(() => { - subject(); + subject({ lintWarning: false }); }); // switch tabs @@ -678,14 +591,10 @@ describe('', () => { }); describe('starting lintWarning at true', () => { - beforeAll(() => { - props.lintWarning = true; - }); - it('lintWarning toggle, starting at true', () => { // render the component with lintWarning prop set to false act(() => { - subject(); + subject({ lintWarning: true }); }); // switch tabs @@ -713,15 +622,12 @@ describe('', () => { }); const testCheckbox = (arialabel, startState, setter) => { - props = { - ...props, - textOutput: startState && arialabel === 'text output on', - soundOutput: startState && arialabel === 'sound output on', - gridOutput: startState && arialabel === 'table output on' - }; - act(() => { - subject(); + subject({ + textOutput: startState && arialabel === 'text output on', + soundOutput: startState && arialabel === 'sound output on', + gridOutput: startState && arialabel === 'table output on' + }); }); // switch tabs @@ -764,17 +670,12 @@ describe('', () => { }); describe('multiple checkboxes can be selected', () => { - beforeAll(() => { - props = { - ...props, - textOutput: true, - gridOutput: true - }; - }); - it('multiple checkboxes can be selected', () => { act(() => { - subject(); + subject({ + textOutput: true, + gridOutput: true + }); }); // switch tabs @@ -797,16 +698,12 @@ describe('', () => { }); describe('none of the checkboxes can be selected', () => { - beforeAll(() => { - props = { - ...props, - textOutput: false, - gridOutput: false - }; - }); it('none of the checkboxes can be selected', () => { act(() => { - subject(); + subject({ + textOutput: false, + gridOutput: false + }); }); // switch tabs diff --git a/client/test-utils.js b/client/test-utils.js index ffabcb0df2..923cd75a4b 100644 --- a/client/test-utils.js +++ b/client/test-utils.js @@ -14,15 +14,14 @@ import { render } from '@testing-library/react'; import React from 'react'; import PropTypes from 'prop-types'; -import { createStore } from 'redux'; import { Provider } from 'react-redux'; import { I18nextProvider } from 'react-i18next'; import { ThemeProvider as StyledThemeProvider } from 'styled-components'; import i18n from './i18n-test'; -import rootReducer from './reducers'; import ThemeProvider from './modules/App/components/ThemeProvider'; +import configureStore from './store'; import theme, { Theme } from './theme'; // re-export everything @@ -42,11 +41,7 @@ Providers.propTypes = { function reduxRender( ui, - { - initialState, - store = createStore(rootReducer, initialState), - ...renderOptions - } = {} + { initialState, store = configureStore(initialState), ...renderOptions } = {} ) { function Wrapper({ children }) { return ( @@ -62,7 +57,7 @@ function reduxRender( children: PropTypes.element.isRequired }; - return render(ui, { wrapper: Wrapper, ...renderOptions }); + return { store, ...render(ui, { wrapper: Wrapper, ...renderOptions }) }; } const customRender = (ui, options) => From 4ae2dbcaaef3c658e0fb9749759339895f78eb2a Mon Sep 17 00:00:00 2001 From: Linda Paiste Date: Sat, 25 Mar 2023 23:15:42 -0500 Subject: [PATCH 8/8] cleanup of MobileIDEView, etc. --- client/components/RootPage.jsx | 8 +- client/components/mobile/ActionStrip.jsx | 2 +- client/components/mobile/Explorer.jsx | 12 +- client/components/mobile/FloatingNav.jsx | 44 -- client/components/mobile/MobileScreen.jsx | 35 +- client/components/mobile/PreferencePicker.jsx | 73 --- client/constants.js | 1 + client/modules/IDE/actions/ide.js | 6 + .../IDE/components/AutosaveHandler.jsx | 85 ++++ client/modules/IDE/components/Editor.jsx | 18 +- .../IDE/components/Preferences/index.jsx | 14 +- .../components/UnsavedChangesIndicator.jsx | 23 + client/modules/IDE/pages/MobileIDEView.jsx | 422 +++++------------- client/modules/IDE/reducers/ide.js | 4 + client/modules/IDE/selectors/users.js | 1 + client/modules/Mobile/MobileDashboardView.jsx | 2 +- client/modules/Mobile/MobilePreferences.jsx | 153 +------ client/modules/Mobile/MobileSketchView.jsx | 85 +--- translations/locales/de/translations.json | 18 - translations/locales/en-US/translations.json | 18 - translations/locales/es-419/translations.json | 18 - translations/locales/fr-CA/translations.json | 19 - translations/locales/hi/translations.json | 19 - translations/locales/ja/translations.json | 18 - translations/locales/pt-BR/translations.json | 18 - translations/locales/sv/translations.json | 18 - translations/locales/uk-UA/translations.json | 18 - translations/locales/zh-CN/translations.json | 18 - translations/locales/zh-TW/translations.json | 18 - 29 files changed, 312 insertions(+), 876 deletions(-) delete mode 100644 client/components/mobile/FloatingNav.jsx delete mode 100644 client/components/mobile/PreferencePicker.jsx create mode 100644 client/modules/IDE/components/AutosaveHandler.jsx create mode 100644 client/modules/IDE/components/UnsavedChangesIndicator.jsx diff --git a/client/components/RootPage.jsx b/client/components/RootPage.jsx index ca6a4723cd..02637fba89 100644 --- a/client/components/RootPage.jsx +++ b/client/components/RootPage.jsx @@ -1,8 +1,9 @@ +import PropTypes from 'prop-types'; import styled from 'styled-components'; import { prop } from '../theme'; const RootPage = styled.div` - min-height: 100%; + min-height: 100vh; display: flex; flex-direction: column; color: ${prop('primaryTextColor')}; @@ -10,4 +11,9 @@ const RootPage = styled.div` height: ${({ fixedHeight }) => fixedHeight || 'initial'}; `; +RootPage.propTypes = { + fixedHeight: PropTypes.string, + children: PropTypes.node.isRequired +}; + export default RootPage; diff --git a/client/components/mobile/ActionStrip.jsx b/client/components/mobile/ActionStrip.jsx index cd46b9fec4..3e792fec8b 100644 --- a/client/components/mobile/ActionStrip.jsx +++ b/client/components/mobile/ActionStrip.jsx @@ -45,7 +45,7 @@ const ActionStrip = ({ actions }) => ( ActionStrip.propTypes = { actions: PropTypes.arrayOf( PropTypes.shape({ - icon: PropTypes.component, + icon: PropTypes.elementType, aria: PropTypes.string.isRequired, action: PropTypes.func.isRequired, inverted: PropTypes.bool diff --git a/client/components/mobile/Explorer.jsx b/client/components/mobile/Explorer.jsx index 0d798e471d..6277e8aa66 100644 --- a/client/components/mobile/Explorer.jsx +++ b/client/components/mobile/Explorer.jsx @@ -1,15 +1,18 @@ +import PropTypes from 'prop-types'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import PropTypes from 'prop-types'; -import Sidebar from './Sidebar'; +import { useSelector } from 'react-redux'; import ConnectedFileNode from '../../modules/IDE/components/FileNode'; +import { selectRootFile } from '../../modules/IDE/selectors/files'; +import Sidebar from './Sidebar'; -const Explorer = ({ id, canEdit, onPressClose }) => { +const Explorer = ({ canEdit, onPressClose }) => { const { t } = useTranslation(); + const root = useSelector(selectRootFile); return ( onPressClose()} /> @@ -18,7 +21,6 @@ const Explorer = ({ id, canEdit, onPressClose }) => { }; Explorer.propTypes = { - id: PropTypes.number.isRequired, onPressClose: PropTypes.func, canEdit: PropTypes.bool }; diff --git a/client/components/mobile/FloatingNav.jsx b/client/components/mobile/FloatingNav.jsx deleted file mode 100644 index 6983ba6c72..0000000000 --- a/client/components/mobile/FloatingNav.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from 'styled-components'; -import { remSize, prop } from '../../theme'; -import IconButton from './IconButton'; - -const FloatingContainer = styled.div` - position: fixed; - right: ${remSize(16)}; - top: ${remSize(80)}; - - text-align: right; - z-index: 3; - - svg { - width: ${remSize(32)}; - } - svg > path { - fill: ${prop('Button.primary.default.background')} !important; - } -`; - -const FloatingNav = ({ items }) => ( - - {items.map(({ icon, onPress }) => ( - - ))} - -); - -FloatingNav.propTypes = { - items: PropTypes.arrayOf( - PropTypes.shape({ - icon: PropTypes.element, - onPress: PropTypes.func - }) - ) -}; - -FloatingNav.defaultProps = { - items: [] -}; - -export default FloatingNav; diff --git a/client/components/mobile/MobileScreen.jsx b/client/components/mobile/MobileScreen.jsx index 6177d00d3e..c658e28fb8 100644 --- a/client/components/mobile/MobileScreen.jsx +++ b/client/components/mobile/MobileScreen.jsx @@ -1,45 +1,18 @@ -import React from 'react'; -import PropTypes from 'prop-types'; import styled from 'styled-components'; -import { remSize, prop } from '../../theme'; +import { remSize } from '../../theme'; +import RootPage from '../RootPage'; -const ScreenWrapper = styled.div` +const Screen = styled(RootPage)` .toast { font-size: ${remSize(12)}; padding: ${remSize(8)}; - border-radius: ${remSize(4)}; width: 92%; top: unset; min-width: unset; bottom: ${remSize(64)}; } - ${({ fullscreen }) => - fullscreen && - ` - display: flex; - width: 100%; - height: 100%; - flex-flow: column; - background-color: ${prop('backgroundColor')} - `} + height: 100vh; `; -const Screen = ({ children, fullscreen, slimheader }) => ( - - {children} - -); - -Screen.defaultProps = { - fullscreen: false, - slimheader: false -}; - -Screen.propTypes = { - children: PropTypes.node.isRequired, - fullscreen: PropTypes.bool, - slimheader: PropTypes.bool -}; - export default Screen; diff --git a/client/components/mobile/PreferencePicker.jsx b/client/components/mobile/PreferencePicker.jsx deleted file mode 100644 index 0a6746d65c..0000000000 --- a/client/components/mobile/PreferencePicker.jsx +++ /dev/null @@ -1,73 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import styled from 'styled-components'; -import { prop, remSize } from '../../theme'; - -const PreferenceTitle = styled.h4.attrs((props) => ({ - ...props, - className: 'preference__title' -}))` - color: ${prop('primaryTextColor')}; -`; - -const Preference = styled.div.attrs((props) => ({ - ...props, - className: 'preference' -}))` - flex-wrap: nowrap !important; - align-items: baseline !important; - justify-items: space-between; -`; - -const OptionLabel = styled.label.attrs({ className: 'preference__option' })` - font-size: ${remSize(14)} !important; -`; - -const PreferencePicker = ({ title, value, onSelect, options }) => ( - - {title} -
- {options.map((option) => ( - - onSelect(option.value)} - aria-label={option.ariaLabel} - name={option.name} - key={`${option.name}-${option.id}-input`} - id={option.id} - className="preference__radio-button" - value={option.value} - checked={value === option.value} - /> - - {option.label} - - - ))} -
-
-); - -PreferencePicker.defaultProps = { - options: [] -}; - -PreferencePicker.propTypes = { - title: PropTypes.string.isRequired, - value: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]).isRequired, - options: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - label: PropTypes.string, - ariaLabel: PropTypes.string - }) - ), - onSelect: PropTypes.func.isRequired -}; - -export default PreferencePicker; diff --git a/client/constants.js b/client/constants.js index 2678e6950b..85341d5e76 100644 --- a/client/constants.js +++ b/client/constants.js @@ -59,6 +59,7 @@ export const CONSOLE_EVENT = 'CONSOLE_EVENT'; export const CLEAR_CONSOLE = 'CLEAR_CONSOLE'; export const EXPAND_CONSOLE = 'EXPAND_CONSOLE'; export const COLLAPSE_CONSOLE = 'COLLAPSE_CONSOLE'; +export const TOGGLE_CONSOLE = 'TOGGLE_CONSOLE'; export const UPDATE_LINT_MESSAGE = 'UPDATE_LINT_MESSAGE'; export const CLEAR_LINT_MESSAGE = 'CLEAR_LINT_MESSAGE'; diff --git a/client/modules/IDE/actions/ide.js b/client/modules/IDE/actions/ide.js index 80a43443bc..37a97bcab2 100644 --- a/client/modules/IDE/actions/ide.js +++ b/client/modules/IDE/actions/ide.js @@ -115,6 +115,12 @@ export function collapseConsole() { }; } +export function toggleConsole() { + return { + type: ActionTypes.TOGGLE_CONSOLE + }; +} + export function openPreferences() { return { type: ActionTypes.OPEN_PREFERENCES diff --git a/client/modules/IDE/components/AutosaveHandler.jsx b/client/modules/IDE/components/AutosaveHandler.jsx new file mode 100644 index 0000000000..8b5aa9106d --- /dev/null +++ b/client/modules/IDE/components/AutosaveHandler.jsx @@ -0,0 +1,85 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { autosaveProject } from '../actions/project'; +import { selectActiveFile } from '../selectors/files'; +import { getIsUserOwner } from '../selectors/users'; + +// Temporary fix: Copy autosave handling from IDEView for use in MobileIDEView. +// TODO: refactor, use shared component or hook in both places, or move into Editor. + +class AutosaveHandler extends React.Component { + componentDidUpdate(prevProps) { + if (this.props.isUserOwner && this.props.project.id) { + if ( + this.props.preferences.autosave && + this.props.ide.unsavedChanges && + !this.props.ide.justOpenedProject + ) { + if ( + this.props.selectedFile.name === prevProps.selectedFile.name && + this.props.selectedFile.content !== prevProps.selectedFile.content + ) { + if (this.autosaveInterval) { + clearTimeout(this.autosaveInterval); + } + this.autosaveInterval = setTimeout(this.props.autosaveProject, 20000); + } + } else if (this.autosaveInterval && !this.props.preferences.autosave) { + clearTimeout(this.autosaveInterval); + this.autosaveInterval = null; + } + } else if (this.autosaveInterval) { + clearTimeout(this.autosaveInterval); + this.autosaveInterval = null; + } + } + + componentWillUnmount() { + clearTimeout(this.autosaveInterval); + this.autosaveInterval = null; + } + + autosaveInterval = null; + + render() { + return null; + } +} + +AutosaveHandler.propTypes = { + ide: PropTypes.shape({ + justOpenedProject: PropTypes.bool.isRequired, + unsavedChanges: PropTypes.bool.isRequired + }).isRequired, + selectedFile: PropTypes.shape({ + id: PropTypes.string.isRequired, + content: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired, + autosaveProject: PropTypes.func.isRequired, + isUserOwner: PropTypes.bool.isRequired, + project: PropTypes.shape({ + id: PropTypes.string + }).isRequired, + preferences: PropTypes.shape({ + autosave: PropTypes.bool.isRequired + }).isRequired +}; + +function mapStateToProps(state) { + return { + selectedFile: selectActiveFile(state), + ide: state.ide, + preferences: state.preferences, + user: state.user, + project: state.project, + isUserOwner: getIsUserOwner(state) + }; +} + +const mapDispatchToProps = { + autosaveProject +}; + +export default connect(mapStateToProps, mapDispatchToProps)(AutosaveHandler); diff --git a/client/modules/IDE/components/Editor.jsx b/client/modules/IDE/components/Editor.jsx index 04c31b2dee..e726c9c4d8 100644 --- a/client/modules/IDE/components/Editor.jsx +++ b/client/modules/IDE/components/Editor.jsx @@ -40,14 +40,10 @@ import { connect } from 'react-redux'; import { bindActionCreators } from 'redux'; import '../../../utils/htmlmixed'; import '../../../utils/p5-javascript'; -import Timer from '../components/Timer'; -import EditorAccessibility from '../components/EditorAccessibility'; import { metaKey } from '../../../utils/metaKey'; - import '../../../utils/codemirror-search'; import beepUrl from '../../../sounds/audioAlert.mp3'; -import UnsavedChangesDotIcon from '../../../images/unsaved-changes-dot.svg'; import RightArrowIcon from '../../../images/right-arrow.svg'; import LeftArrowIcon from '../../../images/left-arrow.svg'; import { getHTMLFile } from '../reducers/files'; @@ -62,6 +58,10 @@ import * as UserActions from '../../User/actions'; import * as ToastActions from '../actions/toast'; import * as ConsoleActions from '../actions/console'; +import Timer from './Timer'; +import EditorAccessibility from './EditorAccessibility'; +import UnsavedChangesIndicator from './UnsavedChangesIndicator'; + emmet(CodeMirror); window.JSHINT = JSHINT; @@ -410,15 +410,7 @@ class Editor extends React.Component {
{this.props.file.name} - - {this.props.unsavedChanges ? ( - - ) : null} - +
diff --git a/client/modules/IDE/components/Preferences/index.jsx b/client/modules/IDE/components/Preferences/index.jsx index b7dbfd83dc..3f8896a0f5 100644 --- a/client/modules/IDE/components/Preferences/index.jsx +++ b/client/modules/IDE/components/Preferences/index.jsx @@ -1,4 +1,6 @@ +import classNames from 'classnames'; import clamp from 'lodash/clamp'; +import PropTypes from 'prop-types'; import React, { useEffect, useRef, useState } from 'react'; import { Helmet } from 'react-helmet'; import { useDispatch, useSelector } from 'react-redux'; @@ -19,7 +21,7 @@ import { setLinewrap } from '../../actions/preferences'; -export default function Preferences() { +export default function Preferences({ className }) { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -68,7 +70,7 @@ export default function Preferences() { const fontSizeInputRef = useRef(); return ( -
+
p5.js Web Editor | Preferences @@ -403,3 +405,11 @@ export default function Preferences() {
); } + +Preferences.propTypes = { + className: PropTypes.string +}; + +Preferences.defaultProps = { + className: '' +}; diff --git a/client/modules/IDE/components/UnsavedChangesIndicator.jsx b/client/modules/IDE/components/UnsavedChangesIndicator.jsx new file mode 100644 index 0000000000..6bd0c11486 --- /dev/null +++ b/client/modules/IDE/components/UnsavedChangesIndicator.jsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; +import UnsavedChangesDotIcon from '../../../images/unsaved-changes-dot.svg'; + +export default function UnsavedChangesIndicator() { + const { t } = useTranslation(); + const hasUnsavedChanges = useSelector((state) => state.ide.unsavedChanges); + + if (!hasUnsavedChanges) { + return null; + } + + return ( + + + + ); +} diff --git a/client/modules/IDE/pages/MobileIDEView.jsx b/client/modules/IDE/pages/MobileIDEView.jsx index 5b41cc7399..7e2bf25754 100644 --- a/client/modules/IDE/pages/MobileIDEView.jsx +++ b/client/modules/IDE/pages/MobileIDEView.jsx @@ -1,69 +1,49 @@ -import React, { useEffect } from 'react'; import PropTypes from 'prop-types'; -import { connect } from 'react-redux'; -import { withRouter } from 'react-router'; -import { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { withRouter } from 'react-router'; import styled from 'styled-components'; - -// Imports to be Refactored -import * as UserActions from '../../User/actions'; -import * as IDEActions from '../actions/ide'; -import * as ProjectActions from '../actions/project'; -import * as ConsoleActions from '../actions/console'; -import * as PreferencesActions from '../actions/preferences'; -import * as EditorAccessibilityActions from '../actions/editorAccessibility'; - -// Local Imports -import Editor from '../components/Editor'; - import { - PlayIcon, - MoreIcon, FolderIcon, + MoreIcon, + PlayIcon, PreferencesIcon, - TerminalIcon, - SaveIcon + SaveIcon, + TerminalIcon } from '../../../common/icons'; -import UnsavedChangesDotIcon from '../../../images/unsaved-changes-dot.svg'; - -import IconButton from '../../../components/mobile/IconButton'; -import Header from '../../../components/mobile/Header'; -import { useIDEKeyHandlers } from '../components/IDEKeyHandlers'; -import Toast from '../components/Toast'; -import Screen from '../../../components/mobile/MobileScreen'; +import Dropdown from '../../../components/Dropdown'; +import ActionStrip from '../../../components/mobile/ActionStrip'; +import MobileExplorer from '../../../components/mobile/Explorer'; import Footer from '../../../components/mobile/Footer'; +import Header from '../../../components/mobile/Header'; +import IconButton from '../../../components/mobile/IconButton'; import IDEWrapper from '../../../components/mobile/IDEWrapper'; -import MobileExplorer from '../../../components/mobile/Explorer'; -import Console from '../components/Console'; +import Screen from '../../../components/mobile/MobileScreen'; +import useAsModal from '../../../components/useAsModal'; import { remSize } from '../../../theme'; +import { logoutUser } from '../../User/actions'; +import { toggleForceDesktop } from '../actions/editorAccessibility'; +import { + collapseConsole, + startSketch, + stopSketch, + toggleConsole +} from '../actions/ide'; +import { + clearPersistedState, + getProject, + saveProject +} from '../actions/project'; +import AutosaveHandler from '../components/AutosaveHandler'; +import Console from '../components/Console'; +import Editor from '../components/Editor'; +import { useIDEKeyHandlers } from '../components/IDEKeyHandlers'; +import Toast from '../components/Toast'; +import UnsavedChangesIndicator from '../components/UnsavedChangesIndicator'; -import ActionStrip from '../../../components/mobile/ActionStrip'; -import useAsModal from '../../../components/useAsModal'; -import Dropdown from '../../../components/Dropdown'; import { selectActiveFile } from '../selectors/files'; -import { getIsUserOwner } from '../selectors/users'; - -import { useEffectWithComparison } from '../hooks/custom-hooks'; - -const withChangeDot = (title, unsavedChanges = false) => ( - - {title} - - {unsavedChanges && ( - - )} - - -); -const getRootFile = (files) => - files && files.filter((file) => file.name === 'root')[0]; -const getRootFileID = (files) => - ((root) => root && root.id)(getRootFile(files)); +import { selectUsername } from '../selectors/users'; const Expander = styled.div` height: ${(props) => (props.expanded ? remSize(160) : remSize(27))}; @@ -73,46 +53,28 @@ const NavItem = styled.li` position: relative; `; -const getNavOptions = ( - username = undefined, - logoutUser = () => {}, - toggleForceDesktop = () => {} -) => { +const NavMenu = () => { const { t } = useTranslation(); - return username - ? [ - { - icon: PreferencesIcon, - title: t('MobileIDEView.Preferences'), - href: '/preferences' - }, - { - icon: PreferencesIcon, - title: t('MobileIDEView.MyStuff'), - href: `/${username}/sketches` - }, - { - icon: PreferencesIcon, - title: t('MobileIDEView.Examples'), - href: '/p5/sketches' - }, - { - icon: PreferencesIcon, - title: t('MobileIDEView.OriginalEditor'), - action: toggleForceDesktop - }, - { - icon: PreferencesIcon, - title: t('MobileIDEView.Logout'), - action: logoutUser - } - ] - : [ + const dispatch = useDispatch(); + + const username = useSelector(selectUsername); + + return ( + dispatch(toggleForceDesktop()) }, - { - icon: PreferencesIcon, - title: t('MobileIDEView.Login'), - href: '/login' - } - ]; -}; - -const autosave = (autosaveInterval, setAutosaveInterval) => ( - props, - prevProps -) => { - const { - autosaveProject, - preferences, - ide, - selectedFile: file, - project, - isUserOwner - } = props; - - const { selectedFile: oldFile } = prevProps; - - const doAutosave = () => autosaveProject(true); - - if (isUserOwner && project.id) { - if (preferences.autosave && ide.unsavedChanges && !ide.justOpenedProject) { - if (file.name === oldFile.name && file.content !== oldFile.content) { - if (autosaveInterval) { - clearTimeout(autosaveInterval); - } - console.log('will save project in 20 seconds'); - setAutosaveInterval(setTimeout(doAutosave, 20000)); - } - } else if (autosaveInterval && !preferences.autosave) { - clearTimeout(autosaveInterval); - setAutosaveInterval(null); - } - } else if (autosaveInterval) { - clearTimeout(autosaveInterval); - setAutosaveInterval(null); - } + username + ? { + icon: PreferencesIcon, + title: t('MobileIDEView.Logout'), + action: () => dispatch(logoutUser()) + } + : { + icon: PreferencesIcon, + title: t('MobileIDEView.Login'), + href: '/login' + } + ].filter(Boolean)} + /> + ); }; -// ide, preferences, project, selectedFile, user, params, unsavedChanges, expandConsole, collapseConsole, -// stopSketch, startSketch, getProject, clearPersistedState, autosaveProject, saveProject, files +const MobileIDEView = ({ params }) => { + const { t } = useTranslation(); + const dispatch = useDispatch(); -const MobileIDEView = (props) => { - // const { - // preferences, ide, editorAccessibility, project, updateLintMessage, clearLintMessage, - // selectedFile, updateFileContent, files, user, params, - // closeEditorOptions, showEditorOptions, logoutUser, - // startRefreshSketch, stopSketch, expandSidebar, collapseSidebar, clearConsole, console, - // showRuntimeErrorWarning, hideRuntimeErrorWarning, startSketch, getProject, clearPersistedState, setUnsavedChanges, - // toggleForceDesktop - // } = props; + const project = useSelector((state) => state.project); - const { - ide, - preferences, - project, - selectedFile, - user, - params, - unsavedChanges, - expandConsole, - collapseConsole, - stopSketch, - startSketch, - getProject, - clearPersistedState, - autosaveProject, - saveProject, - files, - toggleForceDesktop, - logoutUser, - isUserOwner - } = props; + const [cmController, setCmController] = useState(null); - const [cmController, setCmController] = useState(null); // eslint-disable-line + const consoleIsExpanded = useSelector((state) => state.ide.consoleIsExpanded); - const { username } = user; - const { consoleIsExpanded } = ide; + const selectedFile = useSelector(selectActiveFile); const { name: filename } = selectedFile; // Force state reset - useEffect(clearPersistedState, []); useEffect(() => { - stopSketch(); - collapseConsole(); + dispatch(clearPersistedState()); + dispatch(stopSketch()); + dispatch(collapseConsole()); }, []); // Load Project - const [currentProjectID, setCurrentProjectID] = useState(null); useEffect(() => { - if (!username) return; - if (params.project_id && !currentProjectID) { - if (params.project_id !== project.id) { - getProject(params.project_id, params.username); - } + if ( + params.project_id && + params.username && + params.project_id !== project.id + ) { + dispatch(getProject(params.project_id, params.username)); } - setCurrentProjectID(params.project_id); - }, [params, project, username]); + }, [dispatch, params, project.id]); // Screen Modals - const [toggleNavDropdown, NavDropDown] = useAsModal( - - ); + const [toggleNavDropdown, NavDropDown] = useAsModal(); const [toggleExplorer, Explorer] = useAsModal( - (toggle) => ( - - ), + (toggle) => , true ); - // TODO: This behavior could move to - const [autosaveInterval, setAutosaveInterval] = useState(null); - useEffectWithComparison(autosave(autosaveInterval, setAutosaveInterval), { - autosaveProject, - preferences, - ide, - selectedFile, - project, - user, - isUserOwner - }); - useIDEKeyHandlers({ getContent: () => cmController.getContent() }); - const projectActions = [ - { - icon: TerminalIcon, - aria: 'Toggle console open/closed', - action: consoleIsExpanded ? collapseConsole : expandConsole, - inverted: true - }, - { - icon: SaveIcon, - aria: 'Save project', - action: () => saveProject(cmController.getContent(), false, true) - }, - { icon: FolderIcon, aria: 'Open files explorer', action: toggleExplorer } - ]; - return ( - + +
+ {project.name} + + + } subtitle={filename} > @@ -296,10 +169,10 @@ const MobileIDEView = (props) => { { - startSketch(); + dispatch(startSketch()); }} icon={PlayIcon} - aria-label="Run sketch" + aria-label={t('Toolbar.PlaySketchARIA')} />
@@ -315,94 +188,39 @@ const MobileIDEView = (props) => { )} - + dispatch(toggleConsole()), + inverted: true + }, + { + icon: SaveIcon, + aria: t('Common.Save'), // TODO: translation for 'Save project'? + action: () => + dispatch(saveProject(cmController.getContent(), false, true)) + }, + { + icon: FolderIcon, + aria: t('Editor.OpenSketchARIA'), + action: toggleExplorer + } + ]} + />
); }; MobileIDEView.propTypes = { - ide: PropTypes.shape({ - consoleIsExpanded: PropTypes.bool.isRequired - }).isRequired, - - preferences: PropTypes.shape({}).isRequired, - - project: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string.isRequired, - owner: PropTypes.shape({ - username: PropTypes.string, - id: PropTypes.string - }) - }).isRequired, - - selectedFile: PropTypes.shape({ - id: PropTypes.string.isRequired, - content: PropTypes.string.isRequired, - name: PropTypes.string.isRequired - }).isRequired, - - files: PropTypes.arrayOf( - PropTypes.shape({ - id: PropTypes.string.isRequired, - name: PropTypes.string.isRequired, - content: PropTypes.string.isRequired - }) - ).isRequired, - - toggleForceDesktop: PropTypes.func.isRequired, - - user: PropTypes.shape({ - authenticated: PropTypes.bool.isRequired, - id: PropTypes.string, - username: PropTypes.string - }).isRequired, - - logoutUser: PropTypes.func.isRequired, - - getProject: PropTypes.func.isRequired, - clearPersistedState: PropTypes.func.isRequired, params: PropTypes.shape({ project_id: PropTypes.string, username: PropTypes.string - }).isRequired, - - startSketch: PropTypes.func.isRequired, - stopSketch: PropTypes.func.isRequired, - - unsavedChanges: PropTypes.bool.isRequired, - autosaveProject: PropTypes.func.isRequired, - isUserOwner: PropTypes.bool.isRequired, - - expandConsole: PropTypes.func.isRequired, - collapseConsole: PropTypes.func.isRequired, - saveProject: PropTypes.func.isRequired -}; - -function mapStateToProps(state) { - return { - selectedFile: selectActiveFile(state), - ide: state.ide, - files: state.files, - unsavedChanges: state.ide.unsavedChanges, - preferences: state.preferences, - user: state.user, - project: state.project, - console: state.console, - isUserOwner: getIsUserOwner(state) - }; -} - -const mapDispatchToProps = { - ...ProjectActions, - ...IDEActions, - ...ConsoleActions, - ...PreferencesActions, - ...EditorAccessibilityActions, - ...UserActions + }).isRequired }; -export default withRouter( - connect(mapStateToProps, mapDispatchToProps)(MobileIDEView) -); +export default withRouter(MobileIDEView); diff --git a/client/modules/IDE/reducers/ide.js b/client/modules/IDE/reducers/ide.js index 8d45b8b50a..06c16bd5d5 100644 --- a/client/modules/IDE/reducers/ide.js +++ b/client/modules/IDE/reducers/ide.js @@ -54,6 +54,10 @@ const ide = (state = initialState, action) => { return Object.assign({}, state, { consoleIsExpanded: false }); case ActionTypes.EXPAND_CONSOLE: return Object.assign({}, state, { consoleIsExpanded: true }); + case ActionTypes.TOGGLE_CONSOLE: + return Object.assign({}, state, { + consoleIsExpanded: !state.consoleIsExpanded + }); case ActionTypes.OPEN_PREFERENCES: return Object.assign({}, state, { preferencesIsVisible: true }); case ActionTypes.CLOSE_PREFERENCES: diff --git a/client/modules/IDE/selectors/users.js b/client/modules/IDE/selectors/users.js index 021bdc0671..3e638106fe 100644 --- a/client/modules/IDE/selectors/users.js +++ b/client/modules/IDE/selectors/users.js @@ -6,6 +6,7 @@ const getTotalSize = (state) => state.user.totalSize; const getAssetsTotalSize = (state) => state.assets.totalSize; export const getSketchOwner = (state) => state.project.owner; const getUserId = (state) => state.user.id; +export const selectUsername = (state) => state.user.username; const limit = getConfig('UPLOAD_LIMIT') || 250000000; export const getCanUploadMedia = createSelector( diff --git a/client/modules/Mobile/MobileDashboardView.jsx b/client/modules/Mobile/MobileDashboardView.jsx index f5a6beb770..e490c8a02c 100644 --- a/client/modules/Mobile/MobileDashboardView.jsx +++ b/client/modules/Mobile/MobileDashboardView.jsx @@ -198,7 +198,7 @@ const MobileDashboard = ({ params, location }) => { ); return ( - +
{ - // Props - const { - theme, - autosave, - linewrap, - textOutput, - gridOutput, - lineNumbers, - lintWarning - } = useSelector((state) => state.preferences); - - // Actions - const { - setTheme, - setAutosave, - setLinewrap, - setTextOutput, - setGridOutput, - setLineNumbers, - setLintWarning - } = bindActionCreators( - { ...PreferencesActions, ...IdeActions }, - useDispatch() - ); - const { t } = useTranslation(); - const generalSettings = [ - { - title: t('MobilePreferences.Theme'), - value: theme, - options: optionsPickOne( - t('MobilePreferences.Theme'), - t('MobilePreferences.LightTheme'), - t('MobilePreferences.DarkTheme'), - t('MobilePreferences.HighContrastTheme') - ), - onSelect: (x) => setTheme(x) // setTheme - }, - preferenceOnOff( - t('MobilePreferences.Autosave'), - autosave, - setAutosave, - 'autosave' - ), - preferenceOnOff( - t('MobilePreferences.WordWrap'), - linewrap, - setLinewrap, - 'linewrap' - ) - ]; - - const outputSettings = [ - preferenceOnOff( - t('MobilePreferences.PlainText'), - textOutput, - setTextOutput, - 'text output' - ), - preferenceOnOff( - t('MobilePreferences.TableText'), - gridOutput, - setGridOutput, - 'table output' - ) - ]; - - const accessibilitySettings = [ - preferenceOnOff( - t('MobilePreferences.LineNumbers'), - lineNumbers, - setLineNumbers - ), - preferenceOnOff( - t('MobilePreferences.LintWarningSound'), - lintWarning, - setLintWarning - ) - ]; - return ( - +
-
- +
+
-
- - - {t('MobilePreferences.GeneralSettings')} - - {generalSettings.map((option) => ( - - ))} - - - {t('MobilePreferences.Accessibility')} - - {accessibilitySettings.map((option) => ( - - ))} - - - {t('MobilePreferences.AccessibleOutput')} - - - {t('MobilePreferences.UsedScreenReader')} - - {outputSettings.map((option) => ( - - ))} - -
+
); }; -export default withRouter(MobilePreferences); +export default MobilePreferences; diff --git a/client/modules/Mobile/MobileSketchView.jsx b/client/modules/Mobile/MobileSketchView.jsx index 3cbb8ebb78..1ccbb3cfe2 100644 --- a/client/modules/Mobile/MobileSketchView.jsx +++ b/client/modules/Mobile/MobileSketchView.jsx @@ -1,86 +1,39 @@ -import React from 'react'; -import { bindActionCreators } from 'redux'; -import { useSelector, useDispatch } from 'react-redux'; +import React, { useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { ExitIcon } from '../../common/icons'; +import Footer from '../../components/mobile/Footer'; import Header from '../../components/mobile/Header'; import IconButton from '../../components/mobile/IconButton'; -import PreviewFrame from '../IDE/components/PreviewFrame'; import Screen from '../../components/mobile/MobileScreen'; +import { startSketch, stopSketch } from '../IDE/actions/ide'; import Console from '../IDE/components/Console'; -import * as ProjectActions from '../IDE/actions/project'; -import * as IDEActions from '../IDE/actions/ide'; -import * as PreferencesActions from '../IDE/actions/preferences'; -import * as ConsoleActions from '../IDE/actions/console'; -import * as FilesActions from '../IDE/actions/files'; - -import { getHTMLFile } from '../IDE/reducers/files'; - -import { ExitIcon } from '../../common/icons'; -import Footer from '../../components/mobile/Footer'; -import { selectActiveFile } from '../IDE/selectors/files'; +import PreviewFrame from '../IDE/components/PreviewFrame'; import Content from './MobileViewContent'; const MobileSketchView = () => { - const { files, ide, preferences } = useSelector((state) => state); + const { t } = useTranslation(); + const dispatch = useDispatch(); - const htmlFile = useSelector((state) => getHTMLFile(state.files)); - const projectName = useSelector((state) => state.project.name); - const selectedFile = useSelector(selectActiveFile); + useEffect(() => { + dispatch(startSketch()); + return () => { + dispatch(stopSketch()); + }; + }, [dispatch]); - const { - setTextOutput, - setGridOutput, - dispatchConsoleEvent, - endSketchRefresh, - stopSketch, - setBlobUrl, - expandConsole, - clearConsole - } = bindActionCreators( - { - ...ProjectActions, - ...IDEActions, - ...PreferencesActions, - ...ConsoleActions, - ...FilesActions - }, - useDispatch() - ); + const projectName = useSelector((state) => state.project.name); return ( - +
+ } title={projectName} /> - - } - content={selectedFile.content} - isPlaying - isAccessibleOutputPlaying={ide.isAccessibleOutputPlaying} - previewIsRefreshing={ide.previewIsRefreshing} - textOutput={preferences.textOutput} - gridOutput={preferences.gridOutput} - autorefresh={preferences.autorefresh} - setTextOutput={setTextOutput} - setGridOutput={setGridOutput} - dispatchConsoleEvent={dispatchConsoleEvent} - endSketchRefresh={endSketchRefresh} - stopSketch={stopSketch} - setBlobUrl={setBlobUrl} - expandConsole={expandConsole} - clearConsole={clearConsole} - /> +