diff --git a/client/common/icons.jsx b/client/common/icons.jsx index 06f18895..bbc2a662 100644 --- a/client/common/icons.jsx +++ b/client/common/icons.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import styled from 'styled-components'; -import { remSize, prop } from '../theme'; +import { prop } from '../theme'; import SortArrowUp from '../images/sort-arrow-up.svg'; import SortArrowDown from '../images/sort-arrow-down.svg'; import Github from '../images/github.svg'; @@ -10,6 +10,8 @@ import Plus from '../images/plus-icon.svg'; import Close from '../images/close.svg'; import Exit from '../images/exit.svg'; import DropdownArrow from '../images/down-filled-triangle.svg'; +import Preferences from '../images/preferences.svg'; +import Play from '../images/triangle-arrow-right.svg'; // HOC that adds the right web accessibility props // https://www.scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html @@ -70,3 +72,5 @@ export const PlusIcon = withLabel(Plus); export const CloseIcon = withLabel(Close); export const ExitIcon = withLabel(Exit); export const DropdownArrowIcon = withLabel(DropdownArrow); +export const PreferencesIcon = withLabel(Preferences); +export const PlayIcon = withLabel(Play); diff --git a/client/components/mobile/Footer.jsx b/client/components/mobile/Footer.jsx new file mode 100644 index 00000000..5d82d3c7 --- /dev/null +++ b/client/components/mobile/Footer.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import styled from 'styled-components'; +import { prop, remSize } from '../../theme'; + +const background = prop('MobilePanel.default.background'); +const textColor = prop('primaryTextColor'); + +const Footer = styled.div` + position: fixed; + width: 100%; + background: ${background}; + color: ${textColor}; + padding: ${remSize(12)}; + padding-left: ${remSize(32)}; + z-index: 1; + + bottom: 0; +`; + +export default Footer; diff --git a/client/components/mobile/Header.jsx b/client/components/mobile/Header.jsx new file mode 100644 index 00000000..eca2c98e --- /dev/null +++ b/client/components/mobile/Header.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import styled from 'styled-components'; +import { prop, remSize } from '../../theme'; + +const background = prop('MobilePanel.default.background'); +const textColor = prop('primaryTextColor'); + +const Header = styled.div` + position: fixed; + width: 100%; + background: ${background}; + color: ${textColor}; + padding: ${remSize(12)}; + padding-left: ${remSize(16)}; + padding-right: ${remSize(16)}; + z-index: 1; + + display: flex; + flex: 1; + flex-direction: row; + justify-content: flex-start; + align-items: center; + + // TODO: + svg { + height: 2rem; + } +`; + +export default Header; diff --git a/client/components/mobile/IDEWrapper.jsx b/client/components/mobile/IDEWrapper.jsx new file mode 100644 index 00000000..0982cf81 --- /dev/null +++ b/client/components/mobile/IDEWrapper.jsx @@ -0,0 +1,8 @@ +import React from 'react'; +import styled from 'styled-components'; +import { remSize } from '../../theme'; + +export default styled.div` + z-index: 0; + margin-top: ${remSize(16)}; +`; diff --git a/client/components/mobile/IconButton.jsx b/client/components/mobile/IconButton.jsx new file mode 100644 index 00000000..248dd014 --- /dev/null +++ b/client/components/mobile/IconButton.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; +import Button from '../../common/Button'; + +const ButtonWrapper = styled(Button)` +width: 3rem; +> svg { + width: 100%; + height: 100%; +} +`; + +const IconButton = (props) => { + const { icon, ...otherProps } = props; + const Icon = icon; + + return (} + kind={Button.kinds.inline} + focusable="false" + {...otherProps} + />); +}; + +IconButton.propTypes = { + icon: PropTypes.func.isRequired +}; + +export default IconButton; diff --git a/client/components/mobile/MobileScreen.jsx b/client/components/mobile/MobileScreen.jsx new file mode 100644 index 00000000..1e50f80a --- /dev/null +++ b/client/components/mobile/MobileScreen.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +const Screen = ({ children }) => ( +
+ {children} +
+); +Screen.propTypes = { + children: PropTypes.node.isRequired +}; + +export default Screen; diff --git a/client/modules/IDE/components/PreviewFrame.jsx b/client/modules/IDE/components/PreviewFrame.jsx index d4dbbeee..3b193a8f 100644 --- a/client/modules/IDE/components/PreviewFrame.jsx +++ b/client/modules/IDE/components/PreviewFrame.jsx @@ -22,6 +22,23 @@ import { import { hijackConsoleErrorsScript, startTag, getAllScriptOffsets } from '../../../utils/consoleUtils'; + +const shouldRenderSketch = (props, prevProps = undefined) => { + const { isPlaying, previewIsRefreshing, fullView } = props; + + // if the user explicitly clicks on the play button + if (isPlaying && previewIsRefreshing) return true; + + if (!prevProps) return false; + + return (props.isPlaying !== prevProps.isPlaying // if sketch starts or stops playing, want to rerender + || props.isAccessibleOutputPlaying !== prevProps.isAccessibleOutputPlaying // if user switches textoutput preferences + || props.textOutput !== prevProps.textOutput + || props.gridOutput !== prevProps.gridOutput + || props.soundOutput !== prevProps.soundOutput + || (fullView && props.files[0].id !== prevProps.files[0].id)); +}; + class PreviewFrame extends React.Component { constructor(props) { super(props); @@ -30,46 +47,17 @@ class PreviewFrame extends React.Component { componentDidMount() { window.addEventListener('message', this.handleConsoleEvent); + + const props = { + ...this.props, + previewIsRefreshing: this.props.previewIsRefreshing, + isAccessibleOutputPlaying: this.props.isAccessibleOutputPlaying + }; + if (shouldRenderSketch(props)) this.renderSketch(); } componentDidUpdate(prevProps) { - // if sketch starts or stops playing, want to rerender - if (this.props.isPlaying !== prevProps.isPlaying) { - this.renderSketch(); - return; - } - - // if the user explicitly clicks on the play button - if (this.props.isPlaying && this.props.previewIsRefreshing) { - this.renderSketch(); - return; - } - - // if user switches textoutput preferences - if (this.props.isAccessibleOutputPlaying !== prevProps.isAccessibleOutputPlaying) { - this.renderSketch(); - return; - } - - if (this.props.textOutput !== prevProps.textOutput) { - this.renderSketch(); - return; - } - - if (this.props.gridOutput !== prevProps.gridOutput) { - this.renderSketch(); - return; - } - - if (this.props.soundOutput !== prevProps.soundOutput) { - this.renderSketch(); - return; - } - - if (this.props.fullView && this.props.files[0].id !== prevProps.files[0].id) { - this.renderSketch(); - } - + if (shouldRenderSketch(this.props, prevProps)) this.renderSketch(); // small bug - if autorefresh is on, and the usr changes files // in the sketch, preview will reload } @@ -398,7 +386,7 @@ PreviewFrame.propTypes = { clearConsole: PropTypes.func.isRequired, cmController: PropTypes.shape({ getContent: PropTypes.func - }) + }), }; PreviewFrame.defaultProps = { diff --git a/client/modules/IDE/pages/IDEViewMobile.jsx b/client/modules/IDE/pages/IDEViewMobile.jsx index 628e093a..9a638d60 100644 --- a/client/modules/IDE/pages/IDEViewMobile.jsx +++ b/client/modules/IDE/pages/IDEViewMobile.jsx @@ -20,93 +20,54 @@ import { getHTMLFile } from '../reducers/files'; // Local Imports import Editor from '../components/Editor'; -import { prop, remSize } from '../../../theme'; -import { ExitIcon } from '../../../common/icons'; +import { PreferencesIcon, PlayIcon, ExitIcon } from '../../../common/icons'; -const background = prop('Button.default.background'); -const textColor = prop('primaryTextColor'); +import IconButton from '../../../components/mobile/IconButton'; +import Header from '../../../components/mobile/Header'; +import Screen from '../../../components/mobile/MobileScreen'; +import Footer from '../../../components/mobile/Footer'; +import IDEWrapper from '../../../components/mobile/IDEWrapper'; +import { remSize } from '../../../theme'; - -const Header = styled.div` - position: fixed; - width: 100%; - background: ${background}; - color: ${textColor}; - padding: ${remSize(12)}; - padding-left: ${remSize(32)}; - padding-right: ${remSize(32)}; - z-index: 1; - +const IconContainer = styled.div` + margin-left: ${remSize(32)}; display: flex; - flex: 1; - flex-direction: row; - justify-content: flex-start; - align-items: center; `; -const Footer = styled.div` - position: fixed; - width: 100%; - background: ${background}; - color: ${textColor}; - padding: ${remSize(12)}; - padding-left: ${remSize(32)}; - z-index: 1; - - bottom: 0; -`; - -const Content = styled.div` - z-index: 0; - margin-top: ${remSize(16)}; -`; - -const Icon = styled.a` - > svg { - fill: ${textColor}; - color: ${textColor}; - margin-left: ${remSize(16)}; - } -`; - -const IconLinkWrapper = styled(Link)` - width: 3rem; - margin-right: 1.25rem; - margin-left: none; -`; - - -const Screen = ({ children }) => ( -
- {children} -
-); -Screen.propTypes = { - children: PropTypes.node.isRequired -}; - const isUserOwner = ({ project, user }) => (project.owner && project.owner.id === user.id); const IDEViewMobile = (props) => { const { - preferences, ide, editorAccessibility, project, updateLintMessage, clearLintMessage, selectedFile, updateFileContent, files, closeEditorOptions, showEditorOptions, showKeyboardShortcutModal, setUnsavedChanges, startRefreshSketch, stopSketch, expandSidebar, collapseSidebar, clearConsole, console, showRuntimeErrorWarning, hideRuntimeErrorWarning + preferences, ide, editorAccessibility, project, updateLintMessage, clearLintMessage, + selectedFile, updateFileContent, files, + closeEditorOptions, showEditorOptions, showKeyboardShortcutModal, setUnsavedChanges, + startRefreshSketch, stopSketch, expandSidebar, collapseSidebar, clearConsole, console, + showRuntimeErrorWarning, hideRuntimeErrorWarning, startSketch } = props; - const [tmController, setTmController] = useState(null); + const [tmController, setTmController] = useState(null); // eslint-disable-line + const [overlay, setOverlay] = useState(null); // eslint-disable-line return (
- - - -
+ +

{project.name}

{selectedFile.name}

+ + + setOverlay('preferences')} + icon={PreferencesIcon} + aria-label="Open preferences menu" + /> + { startSketch(); }} icon={PlayIcon} aria-label="Run sketch" /> +
- + { runtimeErrorWarningVisible={ide.runtimeErrorWarningVisible} provideController={setTmController} /> - +

Bottom Bar

); @@ -205,6 +166,8 @@ IDEViewMobile.propTypes = { updatedAt: PropTypes.string }).isRequired, + startSketch: PropTypes.func.isRequired, + updateLintMessage: PropTypes.func.isRequired, clearLintMessage: PropTypes.func.isRequired, diff --git a/client/modules/Mobile/MobileSketchView.jsx b/client/modules/Mobile/MobileSketchView.jsx new file mode 100644 index 00000000..37dc5ba9 --- /dev/null +++ b/client/modules/Mobile/MobileSketchView.jsx @@ -0,0 +1,186 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import styled from 'styled-components'; +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 * 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 { remSize } from '../../theme'; + + +const Content = styled.div` + z-index: 0; + margin-top: ${remSize(68)}; +`; + +const MobileSketchView = (props) => { + // TODO: useSelector requires react-redux ^7.1.0 + // const htmlFile = useSelector(state => getHTMLFile(state.files)); + // const jsFiles = useSelector(state => getJSFiles(state.files)); + // const cssFiles = useSelector(state => getCSSFiles(state.files)); + // const files = useSelector(state => state.files); + + const { + htmlFile, files, selectedFile, projectName + } = props; + + // Actions + const { + setTextOutput, setGridOutput, setSoundOutput, + endSketchRefresh, stopSketch, + dispatchConsoleEvent, expandConsole, clearConsole, + setBlobUrl, + } = props; + + const { preferences, ide } = props; + + return ( + +
+ +
+

{projectName}

+


+
+
+ + } + + content={selectedFile.content} + + isPlaying + isAccessibleOutputPlaying={ide.isAccessibleOutputPlaying} + previewIsRefreshing={ide.previewIsRefreshing} + + textOutput={preferences.textOutput} + gridOutput={preferences.gridOutput} + soundOutput={preferences.soundOutput} + autorefresh={preferences.autorefresh} + + setTextOutput={setTextOutput} + setGridOutput={setGridOutput} + setSoundOutput={setSoundOutput} + dispatchConsoleEvent={dispatchConsoleEvent} + endSketchRefresh={endSketchRefresh} + stopSketch={stopSketch} + setBlobUrl={setBlobUrl} + expandConsole={expandConsole} + clearConsole={clearConsole} + /> + +
); +}; + +MobileSketchView.propTypes = { + params: PropTypes.shape({ + project_id: PropTypes.string, + username: PropTypes.string + }).isRequired, + + htmlFile: PropTypes.shape({ + id: PropTypes.string.isRequired, + content: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired, + files: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + content: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + })).isRequired, + + selectedFile: PropTypes.shape({ + id: PropTypes.string.isRequired, + content: PropTypes.string.isRequired, + name: PropTypes.string.isRequired + }).isRequired, + + preferences: PropTypes.shape({ + fontSize: PropTypes.number.isRequired, + autosave: PropTypes.bool.isRequired, + linewrap: PropTypes.bool.isRequired, + lineNumbers: PropTypes.bool.isRequired, + lintWarning: PropTypes.bool.isRequired, + textOutput: PropTypes.bool.isRequired, + gridOutput: PropTypes.bool.isRequired, + soundOutput: PropTypes.bool.isRequired, + theme: PropTypes.string.isRequired, + autorefresh: PropTypes.bool.isRequired + }).isRequired, + + ide: PropTypes.shape({ + isPlaying: PropTypes.bool.isRequired, + isAccessibleOutputPlaying: PropTypes.bool.isRequired, + consoleEvent: PropTypes.array, + modalIsVisible: PropTypes.bool.isRequired, + sidebarIsExpanded: PropTypes.bool.isRequired, + consoleIsExpanded: PropTypes.bool.isRequired, + preferencesIsVisible: PropTypes.bool.isRequired, + projectOptionsVisible: PropTypes.bool.isRequired, + newFolderModalVisible: PropTypes.bool.isRequired, + shareModalVisible: PropTypes.bool.isRequired, + shareModalProjectId: PropTypes.string.isRequired, + shareModalProjectName: PropTypes.string.isRequired, + shareModalProjectUsername: PropTypes.string.isRequired, + editorOptionsVisible: PropTypes.bool.isRequired, + keyboardShortcutVisible: PropTypes.bool.isRequired, + unsavedChanges: PropTypes.bool.isRequired, + infiniteLoop: PropTypes.bool.isRequired, + previewIsRefreshing: PropTypes.bool.isRequired, + infiniteLoopMessage: PropTypes.string.isRequired, + projectSavedTime: PropTypes.string, + previousPath: PropTypes.string.isRequired, + justOpenedProject: PropTypes.bool.isRequired, + errorType: PropTypes.string, + runtimeErrorWarningVisible: PropTypes.bool.isRequired, + uploadFileModalVisible: PropTypes.bool.isRequired + }).isRequired, + + projectName: PropTypes.string.isRequired, + + setTextOutput: PropTypes.func.isRequired, + setGridOutput: PropTypes.func.isRequired, + setSoundOutput: PropTypes.func.isRequired, + dispatchConsoleEvent: PropTypes.func.isRequired, + endSketchRefresh: PropTypes.func.isRequired, + stopSketch: PropTypes.func.isRequired, + setBlobUrl: PropTypes.func.isRequired, + expandConsole: PropTypes.func.isRequired, + clearConsole: PropTypes.func.isRequired, +}; + +function mapStateToProps(state) { + return { + htmlFile: getHTMLFile(state.files), + projectName: state.project.name, + files: state.files, + ide: state.ide, + preferences: state.preferences, + selectedFile: state.files.find(file => file.isSelectedFile) || + state.files.find(file => file.name === 'sketch.js') || + state.files.find(file => file.name !== 'root'), + }; +} + +function mapDispatchToProps(dispatch) { + return bindActionCreators({ + ...ProjectActions, ...IDEActions, ...PreferencesActions, ...ConsoleActions, ...FilesActions + }, dispatch); +} + +export default connect(mapStateToProps, mapDispatchToProps)(MobileSketchView); diff --git a/client/routes.jsx b/client/routes.jsx index e406e357..3a6a4b77 100644 --- a/client/routes.jsx +++ b/client/routes.jsx @@ -3,6 +3,7 @@ import React from 'react'; import App from './modules/App/App'; import IDEView from './modules/IDE/pages/IDEView'; import IDEViewMobile from './modules/IDE/pages/IDEViewMobile'; +import MobileSketchView from './modules/Mobile/MobileSketchView'; import FullView from './modules/IDE/pages/FullView'; import LoginView from './modules/User/pages/LoginView'; import SignupView from './modules/User/pages/SignupView'; @@ -21,7 +22,11 @@ const checkAuth = (store) => { store.dispatch(getUser()); }; +// TODO: This short-circuit seems unnecessary - using the mobile navigator (future) should prevent this from being called const onRouteChange = (store) => { + const path = window.location.pathname; + if (path.includes('/mobile')) return; + store.dispatch(stopSketch()); }; @@ -50,8 +55,9 @@ const routes = store => ( - + + ); diff --git a/client/theme.js b/client/theme.js index 561fd835..5dba9b88 100644 --- a/client/theme.js +++ b/client/theme.js @@ -88,6 +88,13 @@ export default { Icon: { default: grays.middleGray, hover: grays.darker + }, + MobilePanel: { + default: { + foreground: colors.black, + background: grays.light, + border: grays.middleLight, + }, } }, [Theme.dark]: { @@ -120,6 +127,13 @@ export default { Icon: { default: grays.middleLight, hover: grays.lightest + }, + MobilePanel: { + default: { + foreground: grays.light, + background: grays.dark, + border: grays.middleDark, + }, } }, [Theme.contrast]: { @@ -152,6 +166,13 @@ export default { Icon: { default: grays.mediumLight, hover: colors.yellow + }, + MobilePanel: { + default: { + foreground: grays.light, + background: grays.dark, + border: grays.middleDark, + }, } }, }; diff --git a/server/routes/server.routes.js b/server/routes/server.routes.js index bec91b17..f7666f29 100644 --- a/server/routes/server.routes.js +++ b/server/routes/server.routes.js @@ -118,6 +118,14 @@ if (process.env.MOBILE_ENABLED) { router.get('/mobile', (req, res) => { res.send(renderIndex()); }); + + router.get('/mobile/preview', (req, res) => { + res.send(renderIndex()); + }); + + router.get('/mobile/*', (req, res) => { + res.send(renderIndex()); + }); } router.get('/:username/collections/create', (req, res) => {