diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 354baecdb2758cd26f59c525e042a4a9caba5e85..349e20d5e36b533c2a712f3d55e1b594e512e77e 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -8,9 +8,9 @@ import VM from 'scratch-vm'; import Blocks from '../../containers/blocks.jsx'; import CostumeTab from '../../containers/costume-tab.jsx'; -import Controls from '../../containers/controls.jsx'; import TargetPane from '../../containers/target-pane.jsx'; import SoundTab from '../../containers/sound-tab.jsx'; +import StageHeader from '../../containers/stage-header.jsx'; import Stage from '../../containers/stage.jsx'; import {FormattedMessage} from 'react-intl'; @@ -112,7 +112,13 @@ const GUIComponent = props => { <Box className={styles.stageAndTargetWrapper}> <Box className={styles.stageMenuWrapper}> - <Controls vm={vm} /> + <MediaQuery minWidth={layout.fullSizeMinWidth}>{isFullSize => ( + <StageHeader + height={isFullSize ? layout.fullStageHeight : layout.smallerStageHeight} + vm={vm} + width={isFullSize ? layout.fullStageWidth : layout.smallerStageWidth} + /> + )}</MediaQuery> </Box> <Box className={styles.stageWrapper}> <MediaQuery minWidth={layout.fullSizeMinWidth}>{isFullSize => ( diff --git a/src/components/stage-header/icon--unzoom.svg b/src/components/stage-header/icon--unzoom.svg new file mode 100644 index 0000000000000000000000000000000000000000..c929966dc735c3c1aeaadc4c10fdfa82306596f6 Binary files /dev/null and b/src/components/stage-header/icon--unzoom.svg differ diff --git a/src/components/stage-header/icon--zoom.svg b/src/components/stage-header/icon--zoom.svg new file mode 100644 index 0000000000000000000000000000000000000000..62e008983d41b35d568610d17287c2854e2b7ca7 Binary files /dev/null and b/src/components/stage-header/icon--zoom.svg differ diff --git a/src/components/stage-header/stage-header.css b/src/components/stage-header/stage-header.css new file mode 100644 index 0000000000000000000000000000000000000000..c491b82e984982be1d118a92ac8399f1628763ac --- /dev/null +++ b/src/components/stage-header/stage-header.css @@ -0,0 +1,43 @@ +@import "../../css/units.css"; +@import "../../css/colors.css"; + +.stage-header-wrapper { + position: relative; +} + +.stage-header-wrapper-overlay { + position: fixed; + background-color: rgb(232, 237, 241); + top: 0; + left: 0; + right: 0; + z-index: 5000; +} + +.stage-menu-wrapper { + display: flex; + justify-content: space-between; + flex-shrink: 0; + align-items: center; + height: $stage-menu-height; + padding: $space; +} + +.stage-zoom-icon { + text-align: right; + box-sizing: content-box; + width: 1.25rem; + height: 1.25rem; + padding: 0.375rem; + border-radius: 0.25rem; + user-select: none; + cursor: pointer; + transition: 0.2s ease-out; +} + +.stage-zoom-icon:hover { + /* Scale icon image by 1.2, but keep background static */ + width: 1.5rem; + height: 1.5rem; + padding: 0.25rem; +} diff --git a/src/components/stage-header/stage-header.jsx b/src/components/stage-header/stage-header.jsx new file mode 100644 index 0000000000000000000000000000000000000000..635d161ba1ff092f49efb091446f90cdceaeec47 --- /dev/null +++ b/src/components/stage-header/stage-header.jsx @@ -0,0 +1,81 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; +import VM from 'scratch-vm'; +import Box from '../box/box.jsx'; +import Controls from '../../containers/controls.jsx'; + +import zoomIcon from './icon--zoom.svg'; +import unzoomIcon from './icon--unzoom.svg'; +import styles from './stage-header.css'; + +const StageHeaderComponent = function (props) { + const { + className, + height, + isZoomed, + onUnzoom, + onZoom, + titleZoomIcon, + vm, + width, + ...componentProps + } = props; + return isZoomed === false ? ( + <Box className={styles.stageHeaderWrapper}> + <Box + className={styles.stageMenuWrapper} + height={height} + width={width} + > + <Controls vm={vm} /> + <img + className={classNames( + className, + styles.stageZoomIcon + )} + src={zoomIcon} + title={titleZoomIcon} + onClick={onZoom} + {...componentProps} + /> + </Box> + </Box> + ) : ( + <Box className={styles.stageHeaderWrapperOverlay}> + <Box + className={styles.stageMenuWrapper} + height={'100%'} + width={'100%'} + > + <Controls vm={vm} /> + <img + className={classNames( + className, + styles.stageZoomIcon + )} + src={unzoomIcon} + title={titleZoomIcon} + onClick={onUnzoom} + {...componentProps} + /> + </Box> + </Box> + ); +}; +StageHeaderComponent.propTypes = { + className: PropTypes.string, + height: PropTypes.number, + isZoomed: PropTypes.bool.isRequired, + onUnzoom: PropTypes.func.isRequired, + onZoom: PropTypes.func.isRequired, + titleZoomIcon: PropTypes.string, + vm: PropTypes.instanceOf(VM).isRequired, + width: PropTypes.number +}; +StageHeaderComponent.defaultProps = { + width: 480, + height: 360, + titleZoomIcon: 'Zoom Control' +}; +export default StageHeaderComponent; diff --git a/src/components/stage/stage.css b/src/components/stage/stage.css index 9c784e9560d0ba06f371ea06941c29b627963d53..8188751d1d6112ddf66b5f204ffc75e12a23723f 100644 --- a/src/components/stage/stage.css +++ b/src/components/stage/stage.css @@ -36,7 +36,40 @@ overflow: hidden; } -.monitor-wrapper, .color-picker-wrapper { +.stage-wrapper-overlay { + position: fixed; + top: $stage-menu-height; + left: 0; + right: 0; + bottom: 0; + z-index: 5000; + background-color: rgba(255, 255, 255, 1); +} + +.stage-overlay-content { + outline: none; + margin: auto; + border: 3px solid rgb(126, 133, 151); + padding: 0; + margin-top: 3px; + margin-bottom: 3px; + border-radius: $space; + + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; +} + +.stage-overlay-content-border-override { + border: none; +} + +.question-wrapper { + position: absolute; +} + +.monitor-wrapper, .color-picker-wrapper, .queston-wrapper { position: absolute; top: 0; left: 0; diff --git a/src/components/stage/stage.jsx b/src/components/stage/stage.jsx index ae5ff92a6d2d014429dc5f7fdca12102fd20abf1..47981e04ab0f44cbd416212420be64e64aeac819 100644 --- a/src/components/stage/stage.jsx +++ b/src/components/stage/stage.jsx @@ -11,28 +11,47 @@ import styles from './stage.css'; const StageComponent = props => { const { canvasRef, - width, height, + isColorPicking, + isZoomed, + width, colorInfo, onDeactivateColorPicker, - isColorPicking, question, onQuestionAnswered, ...boxProps } = props; + + let heightCorrectedAspect = height; + let widthCorrectedAspect = width; + const spacingBorderAdjustment = 9; + const stageMenuHeightAdjustment = 40; + if (isZoomed) { + heightCorrectedAspect = window.innerHeight - stageMenuHeightAdjustment - spacingBorderAdjustment; + widthCorrectedAspect = heightCorrectedAspect + (heightCorrectedAspect / 3); + if (widthCorrectedAspect > window.innerWidth) { + widthCorrectedAspect = window.innerWidth; + heightCorrectedAspect = widthCorrectedAspect * .75; + } + } return ( <div> <Box - className={classNames(styles.stageWrapper, { - [styles.withColorPicker]: isColorPicking + className={classNames({ + [styles.stageWrapper]: !isZoomed, + [styles.stageWrapperOverlay]: isZoomed, + [styles.withColorPicker]: !isZoomed && isColorPicking })} > <Box - className={styles.stage} + className={classNames( + styles.stage, + {[styles.stageOverlayContent]: isZoomed} + )} componentRef={canvasRef} element="canvas" - height={height} - width={width} + height={heightCorrectedAspect} + width={widthCorrectedAspect} {...boxProps} /> <Box className={styles.monitorWrapper}> @@ -44,10 +63,22 @@ const StageComponent = props => { </Box> ) : null} {question === null ? null : ( - <Question - question={question} - onQuestionAnswered={onQuestionAnswered} - /> + <div + className={classNames( + styles.stageOverlayContent, + styles.stageOverlayContentBorderOverride + )} + > + <div + className={styles.questionWrapper} + style={{width: widthCorrectedAspect}} + > + <Question + question={question} + onQuestionAnswered={onQuestionAnswered} + /> + </div> + </div> )} </Box> {isColorPicking ? ( @@ -64,6 +95,7 @@ StageComponent.propTypes = { colorInfo: Loupe.propTypes.colorInfo, height: PropTypes.number, isColorPicking: PropTypes.bool, + isZoomed: PropTypes.bool.isRequired, onDeactivateColorPicker: PropTypes.func, onQuestionAnswered: PropTypes.func, question: PropTypes.string, diff --git a/src/containers/stage-header.jsx b/src/containers/stage-header.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d3acba94ee1501098034a2eda728f49866e9d00b --- /dev/null +++ b/src/containers/stage-header.jsx @@ -0,0 +1,43 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import VM from 'scratch-vm'; +import {setZoomed} from '../reducers/zoom'; + +import {connect} from 'react-redux'; + +import StageHeaderComponent from '../components/stage-header/stage-header.jsx'; + +// eslint-disable-next-line react/prefer-stateless-function +class StageHeader extends React.Component { + render () { + const { + ...props + } = this.props; + return ( + <StageHeaderComponent + {...props} + /> + ); + } +} + +StageHeader.propTypes = { + height: PropTypes.number, + isZoomed: PropTypes.bool, + vm: PropTypes.instanceOf(VM).isRequired, + width: PropTypes.number +}; + +const mapStateToProps = state => ({ + isZoomed: state.isZoomed +}); + +const mapDispatchToProps = dispatch => ({ + onZoom: () => dispatch(setZoomed(true)), + onUnzoom: () => dispatch(setZoomed(false)) +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(StageHeader); diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx index fcff811bffef4092b0c912a240dd95466f11272b..72ed404d41db0ff594f8acec8187e583f1895348 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -57,6 +57,7 @@ class Stage extends React.Component { this.props.height !== nextProps.height || this.props.isColorPicking !== nextProps.isColorPicking || this.state.colorInfo !== nextState.colorInfo || + this.props.isZoomed !== nextProps.isZoomed || this.state.question !== nextState.question; } componentDidUpdate (prevProps) { @@ -65,6 +66,8 @@ class Stage extends React.Component { } else if (!this.props.isColorPicking && prevProps.isColorPicking) { this.stopColorPickingLoop(); } + this.updateRect(); + this.renderer.resize(this.rect.width, this.rect.height); } componentWillUnmount () { this.detachMouseEvents(this.canvas); @@ -276,6 +279,7 @@ class Stage extends React.Component { Stage.propTypes = { height: PropTypes.number, isColorPicking: PropTypes.bool, + isZoomed: PropTypes.bool, onActivateColorPicker: PropTypes.func, onDeactivateColorPicker: PropTypes.func, vm: PropTypes.instanceOf(VM).isRequired, @@ -283,7 +287,8 @@ Stage.propTypes = { }; const mapStateToProps = state => ({ - isColorPicking: state.colorPicker.active + isColorPicking: state.colorPicker.active, + isZoomed: state.isZoomed }); const mapDispatchToProps = dispatch => ({ diff --git a/src/reducers/gui.js b/src/reducers/gui.js index 97e24dfe8d8ef1a02ebe8791e1889193a76bef0c..83ede0203093375c2515d7b613b93d8056240a26 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -8,12 +8,14 @@ import monitorLayoutReducer from './monitor-layout'; import targetReducer from './targets'; import toolboxReducer from './toolbox'; import vmReducer from './vm'; +import zoomReducer from './zoom'; import {ScratchPaintReducer} from 'scratch-paint'; export default combineReducers({ colorPicker: colorPickerReducer, customProcedures: customProceduresReducer, intl: intlReducer, + isZoomed: zoomReducer, modals: modalReducer, monitors: monitorReducer, monitorLayout: monitorLayoutReducer, diff --git a/src/reducers/zoom.js b/src/reducers/zoom.js new file mode 100644 index 0000000000000000000000000000000000000000..3cd0f5f7fa091fb4ead412bf024f0aa2fcf9c445 --- /dev/null +++ b/src/reducers/zoom.js @@ -0,0 +1,23 @@ +const SET_ZOOMED = 'scratch-gui/Zoomed/SET_ZOOMED'; +const defaultZoomed = false; +const initialState = defaultZoomed; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case SET_ZOOMED: + return action.isZoomed; + default: + return state; + } +}; +const setZoomed = function (isZoomed) { + return { + type: SET_ZOOMED, + isZoomed: isZoomed + }; +}; +export { + reducer as default, + setZoomed +}; diff --git a/test/integration/test.js b/test/integration/test.js index bb0e0918408799017ab9904a98e1a6fac1f3fe67..03151ae3872f77f1827bbf3b134dacf76a5bb968 100644 --- a/test/integration/test.js +++ b/test/integration/test.js @@ -117,6 +117,30 @@ describe('costumes, sounds and variables', () => { await expect(logs).toEqual([]); }); + test('Load a project by ID (fullscreen)', async () => { + const prevSize = driver.manage() + .window() + .getSize(); + await new Promise(resolve => setTimeout(resolve, 2000)); + driver.manage() + .window() + .setSize(1920, 1080); + const projectId = '96708228'; + await loadUri(`${uri}#${projectId}`); + await new Promise(resolve => setTimeout(resolve, 2000)); + await clickXpath('//img[@title="Zoom Control"]'); + await clickXpath('//img[@title="Go"]'); + await new Promise(resolve => setTimeout(resolve, 2000)); + await clickXpath('//img[@title="Stop"]'); + prevSize.then(value => { + driver.manage() + .window() + .setSize(value.width, value.height); + }); + const logs = await getLogs(errorWhitelist); + await expect(logs).toEqual([]); + }); + test('Creating variables', async () => { await loadUri(uri); await clickText('Blocks');