Merge pull request #1467 from ghalestrilo/feature/mobile-sketch-view

Mobile Sketch Preview Screen
This commit is contained in:
Cassie Tarakajian 2020-07-01 16:41:43 -04:00 committed by GitHub
commit ff658c75ff
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 385 additions and 108 deletions

View file

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import styled from 'styled-components'; import styled from 'styled-components';
import { remSize, prop } from '../theme'; import { prop } from '../theme';
import SortArrowUp from '../images/sort-arrow-up.svg'; import SortArrowUp from '../images/sort-arrow-up.svg';
import SortArrowDown from '../images/sort-arrow-down.svg'; import SortArrowDown from '../images/sort-arrow-down.svg';
import Github from '../images/github.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 Close from '../images/close.svg';
import Exit from '../images/exit.svg'; import Exit from '../images/exit.svg';
import DropdownArrow from '../images/down-filled-triangle.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 // HOC that adds the right web accessibility props
// https://www.scottohara.me/blog/2019/05/22/contextual-images-svgs-and-a11y.html // 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 CloseIcon = withLabel(Close);
export const ExitIcon = withLabel(Exit); export const ExitIcon = withLabel(Exit);
export const DropdownArrowIcon = withLabel(DropdownArrow); export const DropdownArrowIcon = withLabel(DropdownArrow);
export const PreferencesIcon = withLabel(Preferences);
export const PlayIcon = withLabel(Play);

View file

@ -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;

View file

@ -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;

View file

@ -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)};
`;

View file

@ -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 (<ButtonWrapper
iconBefore={<Icon />}
kind={Button.kinds.inline}
focusable="false"
{...otherProps}
/>);
};
IconButton.propTypes = {
icon: PropTypes.func.isRequired
};
export default IconButton;

View file

@ -0,0 +1,13 @@
import React from 'react';
import PropTypes from 'prop-types';
const Screen = ({ children }) => (
<div className="fullscreen-preview">
{children}
</div>
);
Screen.propTypes = {
children: PropTypes.node.isRequired
};
export default Screen;

View file

@ -22,6 +22,23 @@ import {
import { hijackConsoleErrorsScript, startTag, getAllScriptOffsets } import { hijackConsoleErrorsScript, startTag, getAllScriptOffsets }
from '../../../utils/consoleUtils'; 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 { class PreviewFrame extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -30,46 +47,17 @@ class PreviewFrame extends React.Component {
componentDidMount() { componentDidMount() {
window.addEventListener('message', this.handleConsoleEvent); window.addEventListener('message', this.handleConsoleEvent);
const props = {
...this.props,
previewIsRefreshing: this.props.previewIsRefreshing,
isAccessibleOutputPlaying: this.props.isAccessibleOutputPlaying
};
if (shouldRenderSketch(props)) this.renderSketch();
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
// if sketch starts or stops playing, want to rerender if (shouldRenderSketch(this.props, prevProps)) this.renderSketch();
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();
}
// small bug - if autorefresh is on, and the usr changes files // small bug - if autorefresh is on, and the usr changes files
// in the sketch, preview will reload // in the sketch, preview will reload
} }
@ -398,7 +386,7 @@ PreviewFrame.propTypes = {
clearConsole: PropTypes.func.isRequired, clearConsole: PropTypes.func.isRequired,
cmController: PropTypes.shape({ cmController: PropTypes.shape({
getContent: PropTypes.func getContent: PropTypes.func
}) }),
}; };
PreviewFrame.defaultProps = { PreviewFrame.defaultProps = {

View file

@ -20,93 +20,54 @@ import { getHTMLFile } from '../reducers/files';
// Local Imports // Local Imports
import Editor from '../components/Editor'; import Editor from '../components/Editor';
import { prop, remSize } from '../../../theme'; import { PreferencesIcon, PlayIcon, ExitIcon } from '../../../common/icons';
import { ExitIcon } from '../../../common/icons';
const background = prop('Button.default.background'); import IconButton from '../../../components/mobile/IconButton';
const textColor = prop('primaryTextColor'); import Header from '../../../components/mobile/Header';
import Screen from '../../../components/mobile/MobileScreen';
import Footer from '../../../components/mobile/Footer';
const Header = styled.div` import IDEWrapper from '../../../components/mobile/IDEWrapper';
position: fixed; import { remSize } from '../../../theme';
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; 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 }) => (
<div className="fullscreen-preview">
{children}
</div>
);
Screen.propTypes = {
children: PropTypes.node.isRequired
};
const isUserOwner = ({ project, user }) => (project.owner && project.owner.id === user.id); const isUserOwner = ({ project, user }) => (project.owner && project.owner.id === user.id);
const IDEViewMobile = (props) => { const IDEViewMobile = (props) => {
const { 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; } = props;
const [tmController, setTmController] = useState(null); const [tmController, setTmController] = useState(null); // eslint-disable-line
const [overlay, setOverlay] = useState(null); // eslint-disable-line
return ( return (
<Screen> <Screen>
<Header> <Header>
<IconLinkWrapper to="/" aria-label="Return to original editor"> <IconButton to="/mobile" icon={ExitIcon} aria-label="Return to original editor" />
<ExitIcon /> <div style={{ marginLeft: '1rem' }}>
</IconLinkWrapper>
<div>
<h2>{project.name}</h2> <h2>{project.name}</h2>
<h3>{selectedFile.name}</h3> <h3>{selectedFile.name}</h3>
</div> </div>
<IconContainer>
<IconButton
onClick={() => setOverlay('preferences')}
icon={PreferencesIcon}
aria-label="Open preferences menu"
/>
<IconButton to="/mobile/preview" onClick={() => { startSketch(); }} icon={PlayIcon} aria-label="Run sketch" />
</IconContainer>
</Header> </Header>
<Content> <IDEWrapper>
<Editor <Editor
lintWarning={preferences.lintWarning} lintWarning={preferences.lintWarning}
linewrap={preferences.linewrap} linewrap={preferences.linewrap}
@ -141,7 +102,7 @@ const IDEViewMobile = (props) => {
runtimeErrorWarningVisible={ide.runtimeErrorWarningVisible} runtimeErrorWarningVisible={ide.runtimeErrorWarningVisible}
provideController={setTmController} provideController={setTmController}
/> />
</Content> </IDEWrapper>
<Footer><h2>Bottom Bar</h2></Footer> <Footer><h2>Bottom Bar</h2></Footer>
</Screen> </Screen>
); );
@ -205,6 +166,8 @@ IDEViewMobile.propTypes = {
updatedAt: PropTypes.string updatedAt: PropTypes.string
}).isRequired, }).isRequired,
startSketch: PropTypes.func.isRequired,
updateLintMessage: PropTypes.func.isRequired, updateLintMessage: PropTypes.func.isRequired,
clearLintMessage: PropTypes.func.isRequired, clearLintMessage: PropTypes.func.isRequired,

View file

@ -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 (
<Screen>
<Header>
<IconButton to="/mobile" icon={ExitIcon} aria-label="Return to original editor" />
<div style={{ marginLeft: '1rem' }}>
<h2>{projectName}</h2>
<h3><br /></h3>
</div>
</Header>
<Content>
<PreviewFrame
htmlFile={htmlFile}
files={files}
head={<link type="text/css" rel="stylesheet" href="/preview-styles.css" />}
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}
/>
</Content>
</Screen>);
};
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);

View file

@ -3,6 +3,7 @@ import React from 'react';
import App from './modules/App/App'; import App from './modules/App/App';
import IDEView from './modules/IDE/pages/IDEView'; import IDEView from './modules/IDE/pages/IDEView';
import IDEViewMobile from './modules/IDE/pages/IDEViewMobile'; import IDEViewMobile from './modules/IDE/pages/IDEViewMobile';
import MobileSketchView from './modules/Mobile/MobileSketchView';
import FullView from './modules/IDE/pages/FullView'; import FullView from './modules/IDE/pages/FullView';
import LoginView from './modules/User/pages/LoginView'; import LoginView from './modules/User/pages/LoginView';
import SignupView from './modules/User/pages/SignupView'; import SignupView from './modules/User/pages/SignupView';
@ -21,7 +22,11 @@ const checkAuth = (store) => {
store.dispatch(getUser()); store.dispatch(getUser());
}; };
// TODO: This short-circuit seems unnecessary - using the mobile <Switch /> navigator (future) should prevent this from being called
const onRouteChange = (store) => { const onRouteChange = (store) => {
const path = window.location.pathname;
if (path.includes('/mobile')) return;
store.dispatch(stopSketch()); store.dispatch(stopSketch());
}; };
@ -50,8 +55,9 @@ const routes = store => (
<Route path="/:username/collections/create" component={DashboardView} /> <Route path="/:username/collections/create" component={DashboardView} />
<Route path="/:username/collections/:collection_id" component={CollectionView} /> <Route path="/:username/collections/:collection_id" component={CollectionView} />
<Route path="/about" component={IDEView} /> <Route path="/about" component={IDEView} />
<Route path="/mobile" component={IDEViewMobile} />
<Route path="/mobile" component={IDEViewMobile} />
<Route path="/mobile/preview" component={MobileSketchView} />
</Route> </Route>
); );

View file

@ -88,6 +88,13 @@ export default {
Icon: { Icon: {
default: grays.middleGray, default: grays.middleGray,
hover: grays.darker hover: grays.darker
},
MobilePanel: {
default: {
foreground: colors.black,
background: grays.light,
border: grays.middleLight,
},
} }
}, },
[Theme.dark]: { [Theme.dark]: {
@ -120,6 +127,13 @@ export default {
Icon: { Icon: {
default: grays.middleLight, default: grays.middleLight,
hover: grays.lightest hover: grays.lightest
},
MobilePanel: {
default: {
foreground: grays.light,
background: grays.dark,
border: grays.middleDark,
},
} }
}, },
[Theme.contrast]: { [Theme.contrast]: {
@ -152,6 +166,13 @@ export default {
Icon: { Icon: {
default: grays.mediumLight, default: grays.mediumLight,
hover: colors.yellow hover: colors.yellow
},
MobilePanel: {
default: {
foreground: grays.light,
background: grays.dark,
border: grays.middleDark,
},
} }
}, },
}; };

View file

@ -118,6 +118,14 @@ if (process.env.MOBILE_ENABLED) {
router.get('/mobile', (req, res) => { router.get('/mobile', (req, res) => {
res.send(renderIndex()); 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) => { router.get('/:username/collections/create', (req, res) => {