diff --git a/client/components/Nav.jsx b/client/components/Nav.jsx index 69cd3d9744..91d8b8ee7b 100644 --- a/client/components/Nav.jsx +++ b/client/components/Nav.jsx @@ -13,6 +13,8 @@ import { setAllAccessibleOutput, setLanguage } from '../modules/IDE/actions/preferences'; +import { DocumentKeyDown } from '../modules/IDE/hooks/useKeyDownHandlers'; +import { selectRootFile } from '../modules/IDE/selectors/files'; import { logoutUser } from '../modules/User/actions'; import getConfig from '../utils/getConfig'; @@ -63,17 +65,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 +79,6 @@ class Nav extends React.PureComponent { }); } - closeDropDown(e) { - if (e.keyCode === 27) { - this.setDropdown('none'); - } - } - handleClick(e) { if (!this.node) { return; @@ -929,6 +921,11 @@ class Nav extends React.PureComponent { {this.renderLeftLayout(navDropdownState)} {this.renderUserMenu(navDropdownState)} + this.setDropdown('none') + }} + /> ); } @@ -1001,7 +998,7 @@ function mapStateToProps(state) { project: state.project, user: state.user, unsavedChanges: state.ide.unsavedChanges, - rootFile: state.files.filter((file) => file.name === 'root')[0], + rootFile: selectRootFile(state), language: state.preferences.language, isUserOwner: getIsUserOwner(state) }; 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/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/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/About.jsx b/client/modules/IDE/components/About.jsx index 6a68828a65..6184d83289 100644 --- a/client/modules/IDE/components/About.jsx +++ b/client/modules/IDE/components/About.jsx @@ -8,7 +8,7 @@ import SquareLogoIcon from '../../../images/p5js-square-logo.svg'; import AsteriskIcon from '../../../images/p5-asterisk.svg'; import packageData from '../../../../package.json'; -function About(props) { +function About() { const { t } = useTranslation(); const p5version = useSelector((state) => { const index = state.files.find((file) => file.name === 'index.html'); 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 6702f57d8f..e726c9c4d8 100644 --- a/client/modules/IDE/components/Editor.jsx +++ b/client/modules/IDE/components/Editor.jsx @@ -40,17 +40,14 @@ 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'; +import { selectActiveFile } from '../selectors/files'; import * as FileActions from '../actions/files'; import * as IDEActions from '../actions/ide'; @@ -61,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; @@ -409,15 +410,7 @@ class Editor extends React.Component {
{this.props.file.name} - - {this.props.unsavedChanges ? ( - - ) : null} - +
@@ -493,10 +486,7 @@ Editor.propTypes = { function mapStateToProps(state) { return { files: state.files, - file: - state.files.find((file) => file.isSelectedFile) || - state.files.find((file) => file.name === 'sketch.js') || - state.files.find((file) => file.name !== 'root'), + file: selectActiveFile(state), htmlFile: getHTMLFile(state.files), ide: state.ide, preferences: state.preferences, 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 new file mode 100644 index 0000000000..9b9cd2d614 --- /dev/null +++ b/client/modules/IDE/components/Modal.jsx @@ -0,0 +1,67 @@ +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 + +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); + }; + }, []); + + useKeyDownHandlers({ escape: onClose }); + + 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/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/modules/IDE/components/Preferences/index.jsx b/client/modules/IDE/components/Preferences/index.jsx index 090d7aeca3..3f8896a0f5 100644 --- a/client/modules/IDE/components/Preferences/index.jsx +++ b/client/modules/IDE/components/Preferences/index.jsx @@ -1,478 +1,415 @@ +import classNames from 'classnames'; +import clamp from 'lodash/clamp'; import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { Helmet } from 'react-helmet'; +import { useDispatch, useSelector } from 'react-redux'; import { Tab, Tabs, TabList, TabPanel } from 'react-tabs'; -import { withTranslation } from 'react-i18next'; -// import { bindActionCreators } from 'redux'; -// import { connect } from 'react-redux'; -// import * as PreferencesActions from '../actions/preferences'; - +import { useTranslation } from 'react-i18next'; import PlusIcon from '../../../../images/plus.svg'; import MinusIcon from '../../../../images/minus.svg'; import beepUrl from '../../../../sounds/audioAlert.mp3'; +import { + setTheme, + setAutosave, + setTextOutput, + setGridOutput, + setFontSize, + setLineNumbers, + setLintWarning, + setAutocloseBracketsQuotes, + setLinewrap +} from '../../actions/preferences'; -class Preferences extends React.Component { - constructor(props) { - super(props); - this.handleUpdateAutosave = this.handleUpdateAutosave.bind(this); - this.handleUpdateLinewrap = this.handleUpdateLinewrap.bind(this); - this.handleLintWarning = this.handleLintWarning.bind(this); - this.handleLineNumbers = this.handleLineNumbers.bind(this); - this.onFontInputChange = this.onFontInputChange.bind(this); - this.onFontInputSubmit = this.onFontInputSubmit.bind(this); - this.increaseFontSize = this.increaseFontSize.bind(this); - this.decreaseFontSize = this.decreaseFontSize.bind(this); - this.setFontSize = this.setFontSize.bind(this); - - this.state = { - fontSize: props.fontSize - }; - } - - onFontInputChange(event) { - const INTEGER_REGEX = /^[0-9\b]+$/; - if (event.target.value === '' || INTEGER_REGEX.test(event.target.value)) { - this.setState({ - fontSize: event.target.value - }); - } - } - - onFontInputSubmit(event) { - event.preventDefault(); - let value = parseInt(this.state.fontSize, 10); - if (Number.isNaN(value)) { - value = 16; - } - if (value > 36) { - value = 36; - } - if (value < 8) { - value = 8; - } - this.setFontSize(value); - } +export default function Preferences({ className }) { + const { t } = useTranslation(); - setFontSize(value) { - this.setState({ fontSize: value }); - this.props.setFontSize(value); - } + const dispatch = useDispatch(); - decreaseFontSize() { - const newValue = Number(this.state.fontSize) - 2; - this.setFontSize(newValue); - } + const { + fontSize, + autosave, + linewrap, + lineNumbers, + lintWarning, + textOutput, + gridOutput, + theme, + autocloseBracketsQuotes + } = useSelector((state) => state.preferences); - increaseFontSize() { - const newValue = Number(this.state.fontSize) + 2; - this.setFontSize(newValue); - } + const validateFontSize = (number) => { + if (Number.isNaN(number)) return 16; + return clamp(number, 8, 36); + }; - handleUpdateAutosave(event) { - const value = event.target.value === 'true'; - this.props.setAutosave(value); - } + // The current contents of the input, which may be invalid. String or number. + const [fontSizeString, setFontSizeString] = useState(fontSize); + const fontSizeNumber = parseInt(fontSizeString, 10); - handleUpdateLinewrap(event) { - const value = event.target.value === 'true'; - this.props.setLinewrap(value); - } + // Immediately submit any valid fontSize input + useEffect(() => { + if (validateFontSize(fontSizeNumber) === fontSizeNumber) { + dispatch(setFontSize(fontSizeNumber)); + } + }, [fontSizeNumber]); - handleLintWarning(event) { - const value = event.target.value === 'true'; - this.props.setLintWarning(value); - } + // Handler for increase and decrease. + const updateFontSize = (value) => { + const valid = validateFontSize(value); + setFontSizeString(valid); + dispatch(setFontSize(valid)); + }; - handleLineNumbers(event) { - const value = event.target.value === 'true'; - this.props.setLineNumbers(value); - } + const onFontInputSubmit = (event) => { + event.preventDefault(); + const valid = validateFontSize(fontSizeNumber); + dispatch(setFontSize(valid)); + }; - render() { - const beep = new Audio(beepUrl); + const fontSizeInputRef = useRef(); - return ( -
- - p5.js Web Editor | Preferences - - - -
- -

- {this.props.t('Preferences.GeneralSettings')} -

-
- -

- {this.props.t('Preferences.Accessibility')} -

-
-
-
- -
-

- {this.props.t('Preferences.Theme')} + return ( +
+ + p5.js Web Editor | Preferences + + + +
+ +

+ {t('Preferences.GeneralSettings')}

-
- this.props.setTheme('light')} - aria-label={this.props.t('Preferences.LightThemeARIA')} - name="light theme" - id="light-theme-on" - className="preference__radio-button" - value="light" - checked={this.props.theme === 'light'} - /> - - this.props.setTheme('dark')} - aria-label={this.props.t('Preferences.DarkThemeARIA')} - name="dark theme" - id="dark-theme-on" - className="preference__radio-button" - value="dark" - checked={this.props.theme === 'dark'} - /> - - this.props.setTheme('contrast')} - aria-label={this.props.t('Preferences.HighContrastThemeARIA')} - name="high contrast theme" - id="high-contrast-theme-on" - className="preference__radio-button" - value="contrast" - checked={this.props.theme === 'contrast'} - /> - -
-
-
-

- {this.props.t('Preferences.TextSize')} -

- -
- - { - this.fontSizeInput = element; - }} - onClick={() => { - this.fontSizeInput.select(); - }} - /> -
-
+
+ +
+

{t('Preferences.Theme')}

+
+ dispatch(setTheme('light'))} + aria-label={t('Preferences.LightThemeARIA')} + name="light theme" + id="light-theme-on" + className="preference__radio-button" + value="light" + checked={theme === 'light'} + /> + + dispatch(setTheme('dark'))} + aria-label={t('Preferences.DarkThemeARIA')} + name="dark theme" + id="dark-theme-on" + className="preference__radio-button" + value="dark" + checked={theme === 'dark'} + /> + + dispatch(setTheme('contrast'))} + aria-label={t('Preferences.HighContrastThemeARIA')} + name="high contrast theme" + id="high-contrast-theme-on" + className="preference__radio-button" + value="contrast" + checked={theme === 'contrast'} + /> +
-
-

- {this.props.t('Preferences.Autosave')} -

-
- this.props.setAutosave(true)} - aria-label={this.props.t('Preferences.AutosaveOnARIA')} - name="autosave" - id="autosave-on" - className="preference__radio-button" - value="On" - checked={this.props.autosave} - /> - - this.props.setAutosave(false)} - aria-label={this.props.t('Preferences.AutosaveOffARIA')} - name="autosave" - id="autosave-off" - className="preference__radio-button" - value="Off" - checked={!this.props.autosave} - /> - -
+
+
+

{t('Preferences.TextSize')}

+ +
+ + setFontSizeString(e.target.value)} + type="number" + ref={fontSizeInputRef} + onClick={() => fontSizeInputRef.current?.select()} + /> +
+ +
+
+

{t('Preferences.Autosave')}

+
+ dispatch(setAutosave(true))} + aria-label={t('Preferences.AutosaveOnARIA')} + name="autosave" + id="autosave-on" + className="preference__radio-button" + value="On" + checked={autosave} + /> + + dispatch(setAutosave(false))} + aria-label={t('Preferences.AutosaveOffARIA')} + name="autosave" + id="autosave-off" + className="preference__radio-button" + value="Off" + checked={!autosave} + /> +
-
-

- {this.props.t('Preferences.AutocloseBracketsQuotes')} -

-
- this.props.setAutocloseBracketsQuotes(true)} - aria-label={this.props.t( - 'Preferences.AutocloseBracketsQuotesOnARIA' - )} - name="autoclosebracketsquotes" - id="autoclosebracketsquotes-on" - className="preference__radio-button" - value="On" - checked={this.props.autocloseBracketsQuotes} - /> - - this.props.setAutocloseBracketsQuotes(false)} - aria-label={this.props.t( - 'Preferences.AutocloseBracketsQuotesOffARIA' - )} - name="autoclosebracketsquotes" - id="autoclosebracketsquotes-off" - className="preference__radio-button" - value="Off" - checked={!this.props.autocloseBracketsQuotes} - /> - -
+
+
+

+ {t('Preferences.AutocloseBracketsQuotes')} +

+
+ dispatch(setAutocloseBracketsQuotes(true))} + aria-label={t('Preferences.AutocloseBracketsQuotesOnARIA')} + name="autoclosebracketsquotes" + id="autoclosebracketsquotes-on" + className="preference__radio-button" + value="On" + checked={autocloseBracketsQuotes} + /> + + dispatch(setAutocloseBracketsQuotes(false))} + aria-label={t('Preferences.AutocloseBracketsQuotesOffARIA')} + name="autoclosebracketsquotes" + id="autoclosebracketsquotes-off" + className="preference__radio-button" + value="Off" + checked={!autocloseBracketsQuotes} + /> +
-
-

- {this.props.t('Preferences.WordWrap')} -

-
- this.props.setLinewrap(true)} - aria-label={this.props.t('Preferences.LineWrapOnARIA')} - name="linewrap" - id="linewrap-on" - className="preference__radio-button" - value="On" - checked={this.props.linewrap} - /> - - this.props.setLinewrap(false)} - aria-label={this.props.t('Preferences.LineWrapOffARIA')} - name="linewrap" - id="linewrap-off" - className="preference__radio-button" - value="Off" - checked={!this.props.linewrap} - /> - -
+
+
+

{t('Preferences.WordWrap')}

+
+ dispatch(setLinewrap(true))} + aria-label={t('Preferences.LineWrapOnARIA')} + name="linewrap" + id="linewrap-on" + className="preference__radio-button" + value="On" + checked={linewrap} + /> + + dispatch(setLinewrap(false))} + aria-label={t('Preferences.LineWrapOffARIA')} + name="linewrap" + id="linewrap-off" + className="preference__radio-button" + value="Off" + checked={!linewrap} + /> +
- - -
-

- {this.props.t('Preferences.LineNumbers')} -

-
- this.props.setLineNumbers(true)} - aria-label={this.props.t('Preferences.LineNumbersOnARIA')} - name="line numbers" - id="line-numbers-on" - className="preference__radio-button" - value="On" - checked={this.props.lineNumbers} - /> - - this.props.setLineNumbers(false)} - aria-label={this.props.t('Preferences.LineNumbersOffARIA')} - name="line numbers" - id="line-numbers-off" - className="preference__radio-button" - value="Off" - checked={!this.props.lineNumbers} - /> - -
+
+
+ +
+

+ {t('Preferences.LineNumbers')} +

+
+ dispatch(setLineNumbers(true))} + aria-label={t('Preferences.LineNumbersOnARIA')} + name="line numbers" + id="line-numbers-on" + className="preference__radio-button" + value="On" + checked={lineNumbers} + /> + + dispatch(setLineNumbers(false))} + aria-label={t('Preferences.LineNumbersOffARIA')} + name="line numbers" + id="line-numbers-off" + className="preference__radio-button" + value="Off" + checked={!lineNumbers} + /> +
-
-

- {this.props.t('Preferences.LintWarningSound')} -

-
- this.props.setLintWarning(true)} - aria-label={this.props.t('Preferences.LintWarningOnARIA')} - name="lint warning" - id="lint-warning-on" - className="preference__radio-button" - value="On" - checked={this.props.lintWarning} - /> - - this.props.setLintWarning(false)} - aria-label={this.props.t('Preferences.LintWarningOffARIA')} - name="lint warning" - id="lint-warning-off" - className="preference__radio-button" - value="Off" - checked={!this.props.lintWarning} - /> - - -
+
+
+

+ {t('Preferences.LintWarningSound')} +

+
+ dispatch(setLintWarning(true))} + aria-label={t('Preferences.LintWarningOnARIA')} + name="lint warning" + id="lint-warning-on" + className="preference__radio-button" + value="On" + checked={lintWarning} + /> + + dispatch(setLintWarning(false))} + aria-label={t('Preferences.LintWarningOffARIA')} + name="lint warning" + id="lint-warning-off" + className="preference__radio-button" + value="Off" + checked={!lintWarning} + /> + +
-
-

- {this.props.t('Preferences.AccessibleTextBasedCanvas')} -

-
- {this.props.t('Preferences.UsedScreenReader')} -
+
+
+

+ {t('Preferences.AccessibleTextBasedCanvas')} +

+
+ {t('Preferences.UsedScreenReader')} +
-
- { - this.props.setTextOutput(event.target.checked); - }} - aria-label={this.props.t('Preferences.TextOutputARIA')} - name="text output" - id="text-output-on" - value="On" - checked={this.props.textOutput} - /> - - { - this.props.setGridOutput(event.target.checked); - }} - aria-label={this.props.t('Preferences.TableOutputARIA')} - name="table output" - id="table-output-on" - value="On" - checked={this.props.gridOutput} - /> - -
+
+ { + dispatch(setTextOutput(event.target.checked)); + }} + aria-label={t('Preferences.TextOutputARIA')} + name="text output" + id="text-output-on" + value="On" + checked={textOutput} + /> + + { + dispatch(setGridOutput(event.target.checked)); + }} + aria-label={t('Preferences.TableOutputARIA')} + name="table output" + id="table-output-on" + value="On" + checked={gridOutput} + /> +
- - -
- ); - } +

+
+
+
+ ); } Preferences.propTypes = { - fontSize: PropTypes.number.isRequired, - lineNumbers: PropTypes.bool.isRequired, - setFontSize: PropTypes.func.isRequired, - autosave: PropTypes.bool.isRequired, - linewrap: PropTypes.bool.isRequired, - setLineNumbers: PropTypes.func.isRequired, - setAutosave: PropTypes.func.isRequired, - setLinewrap: PropTypes.func.isRequired, - textOutput: PropTypes.bool.isRequired, - gridOutput: PropTypes.bool.isRequired, - setTextOutput: PropTypes.func.isRequired, - setGridOutput: PropTypes.func.isRequired, - lintWarning: PropTypes.bool.isRequired, - setLintWarning: PropTypes.func.isRequired, - theme: PropTypes.string.isRequired, - setTheme: PropTypes.func.isRequired, - autocloseBracketsQuotes: PropTypes.bool.isRequired, - setAutocloseBracketsQuotes: PropTypes.func.isRequired, - t: PropTypes.func.isRequired + className: PropTypes.string }; -export default withTranslation()(Preferences); +Preferences.defaultProps = { + className: '' +}; diff --git a/client/modules/IDE/components/ShareModal.jsx b/client/modules/IDE/components/ShareModal.jsx index ff7b80b7a3..c8641e08dc 100644 --- a/client/modules/IDE/components/ShareModal.jsx +++ b/client/modules/IDE/components/ShareModal.jsx @@ -1,11 +1,19 @@ -import PropTypes from 'prop-types'; import React from 'react'; import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; import CopyableInput from './CopyableInput'; // import getConfig from '../../../utils/getConfig'; -const ShareModal = ({ projectId, ownerUsername, projectName }) => { +export default function ShareModal() { const { t } = useTranslation(); + + // TODO: store these as nested properties instead of top-level + const projectId = useSelector((state) => state.ide.shareModalProjectId); + const projectName = useSelector((state) => state.ide.shareModalProjectName); + const ownerUsername = useSelector( + (state) => state.ide.shareModalProjectUsername + ); + const hostname = window.location.origin; // const previewUrl = getConfig('PREVIEW_URL'); return ( @@ -17,10 +25,10 @@ const ShareModal = ({ projectId, ownerUsername, projectName }) => { /> {/* CAT removing due to phishing issues */} {/* */} + label={t('ShareModal.Present')} + hasPreviewLink + value={`${previewUrl}/${ownerUsername}/present/${projectId}`} + /> */} { /> ); -}; - -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/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/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/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/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 5b71444fab..13ed825a4c 100644 --- a/client/modules/IDE/pages/IDEView.jsx +++ b/client/modules/IDE/pages/IDEView.jsx @@ -1,40 +1,29 @@ 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'; 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'; -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; @@ -62,7 +51,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 +71,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 +133,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 +160,7 @@ class IDEView extends React.Component { {getTitle(this.props)} + this.cmController.getContent()} />