diff --git a/package.json b/package.json index 3ae8de45afa2d36bbc84c21792c4c0d1662634bf..d293d00de07edb67468d04e1030c09b769c83455 100644 --- a/package.json +++ b/package.json @@ -24,14 +24,14 @@ "url": "git+ssh://git@github.com/LLK/scratch-gui.git" }, "peerDependencies": { - "react": "^15.6.0", - "react-dom": "^15.6.0" + "react": "^15.6.1", + "react-dom": "^15.6.1" }, "devDependencies": { "autoprefixer": "7.1.2", - "babel-cli": "6.24.1", + "babel-cli": "6.26.0", "babel-core": "^6.23.1", - "babel-eslint": "^7.1.1", + "babel-eslint": "^7.2.3", "babel-loader": "^7.1.0", "babel-plugin-react-intl": "2.3.1", "babel-plugin-syntax-dynamic-import": "6.18.0", @@ -42,13 +42,12 @@ "chromedriver": "^2.31.0", "classnames": "2.2.5", "copy-webpack-plugin": "4.0.1", - "css-loader": "0.28.3", + "css-loader": "0.28.5", "enzyme": "^2.8.2", - "eslint": "^3.16.1", - "eslint-config-import": "^0.13.0", - "eslint-config-scratch": "^3.0.0", + "eslint": "^4.4.1", + "eslint-config-scratch": "^4.0.0", "eslint-plugin-import": "^2.7.0", - "eslint-plugin-react": "^7.0.1", + "eslint-plugin-react": "^7.2.1", "get-float-time-domain-data": "0.1.0", "gh-pages": "github:rschamp/gh-pages#publish-branch-to-subfolder", "html-webpack-plugin": "2.30.0", @@ -66,9 +65,9 @@ "postcss-loader": "^2.0.5", "postcss-simple-vars": "^4.0.0", "prop-types": "^15.5.10", - "react": "15.5.4", + "react": "15.6.1", "react-contextmenu": "2.6.5", - "react-dom": "15.5.4", + "react-dom": "15.6.1", "react-draggable": "2.2.6", "react-intl": "2.3.0", "react-intl-redux": "0.6.0", diff --git a/src/.eslintrc.js b/src/.eslintrc.js index e558dde263eb1f6970b0abcbf62e9f28ab655580..ff88f0f27fa8ea14df758aed45b27ba9460806d5 100644 --- a/src/.eslintrc.js +++ b/src/.eslintrc.js @@ -1,6 +1,6 @@ module.exports = { root: true, - extends: ['scratch', 'scratch/es6', 'scratch/react', 'import'], + extends: ['scratch', 'scratch/es6', 'scratch/react', 'plugin:import/errors'], env: { browser: true }, diff --git a/src/components/audio-trimmer/audio-trimmer.css b/src/components/audio-trimmer/audio-trimmer.css index 454887ab3da3d20c6d125c6d89cc8815a7cf094d..fe276c3819eed948b33d34cde90da60f19bca77f 100644 --- a/src/components/audio-trimmer/audio-trimmer.css +++ b/src/components/audio-trimmer/audio-trimmer.css @@ -16,6 +16,7 @@ $hover-scale: 2; .trim-background { cursor: pointer; + touch-action: none; } .trim-background-mask { diff --git a/src/components/audio-trimmer/audio-trimmer.jsx b/src/components/audio-trimmer/audio-trimmer.jsx index b7bbe380fe26b0371edd7a03eac205a0aa71b33d..d1b21c92664cebc586212c9833629ba90da8f701 100644 --- a/src/components/audio-trimmer/audio-trimmer.jsx +++ b/src/components/audio-trimmer/audio-trimmer.jsx @@ -17,6 +17,7 @@ const AudioTrimmer = props => ( width: `${100 * props.trimStart}%` }} onMouseDown={props.onTrimStartMouseDown} + onTouchStart={props.onTrimStartMouseDown} > <Box className={classNames(styles.absolute, styles.trimBackgroundMask)} /> <Box className={classNames(styles.trimLine, styles.startTrimLine)}> @@ -47,6 +48,7 @@ const AudioTrimmer = props => ( width: `${100 - (100 * props.trimEnd)}%` }} onMouseDown={props.onTrimEndMouseDown} + onTouchStart={props.onTrimEndMouseDown} > <Box className={classNames(styles.absolute, styles.trimBackgroundMask)} /> <Box className={classNames(styles.trimLine, styles.endTrimLine)}> diff --git a/src/components/close-button/close-button.css b/src/components/close-button/close-button.css index b679f8fb27a1bfddb9a7b6895efa5b6315855951..1864e7b2aaede98cf6f4b827db6ba63586275f47 100644 --- a/src/components/close-button/close-button.css +++ b/src/components/close-button/close-button.css @@ -9,7 +9,6 @@ overflow: hidden; /* Mask the icon animation */ background-color: rgba(0, 0, 0, 0.1); border-radius: 50%; - box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; user-select: none; cursor: pointer; @@ -22,8 +21,8 @@ } .small { - width: 1rem; - height: 1rem; + width: 0.825rem; + height: 0.825rem; color: #FFF; background-color: $motion-primary; } @@ -31,6 +30,7 @@ .large { width: 1.75rem; height: 1.75rem; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.1); } .close-icon { @@ -42,7 +42,7 @@ } .small .close-icon { - width: 40%; + width: 50%; } .large .close-icon { diff --git a/src/components/controls/controls.css b/src/components/controls/controls.css new file mode 100644 index 0000000000000000000000000000000000000000..7e60ae33419c73f93a82da5e6d1c21e7c87d6a6a --- /dev/null +++ b/src/components/controls/controls.css @@ -0,0 +1,3 @@ +.controls-container { + display: flex; +} diff --git a/src/components/controls/controls.jsx b/src/components/controls/controls.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8fec705b08ac9ad19a3d5a1669fded868b4b8a3a --- /dev/null +++ b/src/components/controls/controls.jsx @@ -0,0 +1,53 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import GreenFlag from '../green-flag/green-flag.jsx'; +import StopAll from '../stop-all/stop-all.jsx'; +import TurboMode from '../turbo-mode/turbo-mode.jsx'; + +import styles from './controls.css'; + +const Controls = function (props) { + const { + active, + className, + onGreenFlagClick, + onStopAllClick, + turbo, + ...componentProps + } = props; + return ( + <div + className={classNames(styles.controlsContainer, className)} + {...componentProps} + > + <GreenFlag + active={active} + onClick={onGreenFlagClick} + /> + <StopAll + active={active} + onClick={onStopAllClick} + /> + {turbo ? ( + <TurboMode /> + ) : null} + </div> + ); +}; + +Controls.propTypes = { + active: PropTypes.bool, + className: PropTypes.string, + onGreenFlagClick: PropTypes.func.isRequired, + onStopAllClick: PropTypes.func.isRequired, + turbo: PropTypes.bool +}; + +Controls.defaultProps = { + active: false, + turbo: false +}; + +export default Controls; diff --git a/src/components/costume-canvas/costume-canvas.jsx b/src/components/costume-canvas/costume-canvas.jsx index eafe92930f7eaf5f4dbfcd246b93ef7d6796bb25..a142e8d27e642fbd4225d2e8f7a87ab6cf1ab34f 100644 --- a/src/components/costume-canvas/costume-canvas.jsx +++ b/src/components/costume-canvas/costume-canvas.jsx @@ -21,7 +21,7 @@ class CostumeCanvas extends React.Component { prevProps.width !== this.props.width || prevProps.height !== this.props.height || prevProps.direction !== this.props.direction - ) { + ) { this.draw(); } } diff --git a/src/components/forms/input.css b/src/components/forms/input.css index 91b42d4cc59e69dc505975a90d22db22891b5253..6d5eefa52d5b2f4d5c0c558fb37aa1f05ad5e7c0 100644 --- a/src/components/forms/input.css +++ b/src/components/forms/input.css @@ -2,11 +2,13 @@ @import "../../css/colors.css"; .input-form { - padding: $space 0.75rem; + height: 2rem; + padding: 0 0.75rem; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 0.625rem; font-weight: bold; + color: $text-primary; border-width: 1px; border-style: solid; diff --git a/src/components/green-flag/green-flag.css b/src/components/green-flag/green-flag.css index 07e57621bbbe2b115812827f22497a6398fb0e49..2c145283869ac0788d3e5be4f96e8b6afd04594f 100644 --- a/src/components/green-flag/green-flag.css +++ b/src/components/green-flag/green-flag.css @@ -1,14 +1,21 @@ .green-flag { - width: 1.1rem; - height: 1.1rem; - opacity: 0.5; - margin: 0.25rem 0.6rem; + box-sizing: content-box; + width: 1.25rem; + height: 1.25rem; + padding: 0.375rem; + border-radius: 0.25rem; user-select: none; cursor: pointer; - transition: opacity 0.2s ease-out; /* @todo: standardize with var */ + transition: 0.2s ease-out; } -.green-flag.is-active, .green-flag:hover { - opacity: 1; + /* Scale flag image by 1.2, but keep background static */ + width: 1.5rem; + height: 1.5rem; + padding: 0.25rem; +} + +.green-flag.is-active { + background-color: rgba(0, 0, 0, 0.1); } diff --git a/src/components/green-flag/green-flag.jsx b/src/components/green-flag/green-flag.jsx index 692a347cd1eac1b8c33a56973c50bf0ca256d7c6..6b8733c1db116fd53c4a91970206e8596244cd6c 100644 --- a/src/components/green-flag/green-flag.jsx +++ b/src/components/green-flag/green-flag.jsx @@ -8,16 +8,20 @@ import styles from './green-flag.css'; const GreenFlagComponent = function (props) { const { active, + className, onClick, title, ...componentProps } = props; return ( <img - className={classNames({ - [styles.greenFlag]: true, - [styles.isActive]: active - })} + className={classNames( + className, + styles.greenFlag, + { + [styles.isActive]: active + } + )} src={greenFlagIcon} title={title} onClick={onClick} @@ -27,6 +31,7 @@ const GreenFlagComponent = function (props) { }; GreenFlagComponent.propTypes = { active: PropTypes.bool, + className: PropTypes.string, onClick: PropTypes.func.isRequired, title: PropTypes.string }; diff --git a/src/components/green-flag/icon-green-flag.svg b/src/components/green-flag/icon-green-flag.svg deleted file mode 100644 index 4bd2528f9be697e4b8a35158c217fffe81c8309b..0000000000000000000000000000000000000000 Binary files a/src/components/green-flag/icon-green-flag.svg and /dev/null differ diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index 5146c84f16e5340ab0be74a4340f40c3533ebbd6..c8a9285a2a204bee92158053e3c268c4bc4967cd 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -123,10 +123,7 @@ } .target-wrapper { - /* Take all the available vertical space available. - Works in tandem with height: 100%; which is set on the child: .targetPane - @todo: Not working in Safari, not great in FFx - */ + display: flex; flex-grow: 1; flex-basis: 0; diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 1e7fe027e54d69c1caead489e3bb5f9f6dde6fdf..1eb0e65054565751b964170a6cc989b46869364a 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -8,11 +8,10 @@ import VM from 'scratch-vm'; import Blocks from '../../containers/blocks.jsx'; import CostumeTab from '../../containers/costume-tab.jsx'; -import GreenFlag from '../../containers/green-flag.jsx'; +import Controls from '../../containers/controls.jsx'; import TargetPane from '../../containers/target-pane.jsx'; import SoundTab from '../../containers/sound-tab.jsx'; import Stage from '../../containers/stage.jsx'; -import StopAll from '../../containers/stop-all.jsx'; import Box from '../box/box.jsx'; import MenuBar from '../menu-bar/menu-bar.jsx'; @@ -90,8 +89,7 @@ const GUIComponent = props => { <Box className={styles.stageAndTargetWrapper}> <Box className={styles.stageMenuWrapper}> - <GreenFlag vm={vm} /> - <StopAll vm={vm} /> + <Controls vm={vm} /> </Box> <Box className={styles.stageWrapper}> <MediaQuery minWidth={layout.fullSizeMinWidth}>{isFullSize => ( diff --git a/src/components/library-item/library-item.css b/src/components/library-item/library-item.css index 12253a55202602010b71b2a98014d1ecbbe787df..067b2c1af3328a80dd5e228d810d6129f1788617 100644 --- a/src/components/library-item/library-item.css +++ b/src/components/library-item/library-item.css @@ -1,3 +1,4 @@ +@import "../../css/colors.css"; @import "../../css/units.css"; .library-item { @@ -11,7 +12,7 @@ margin: $space; padding: 1rem 1rem 0 1rem; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - color: #575e75; + color: $text-primary; background: white; border-width: 2px; border-style: solid; diff --git a/src/components/sound-editor/sound-editor.css b/src/components/sound-editor/sound-editor.css index 88fb5773f14dc0589be7a2796f5a3d9b6a900fe5..257c59d655357a096638cc18c6ac6b4dbde5a3b6 100644 --- a/src/components/sound-editor/sound-editor.css +++ b/src/components/sound-editor/sound-editor.css @@ -49,7 +49,7 @@ $border-radius: 0.25rem; transition: 0.2s; } -.button img { +.button > img { flex-grow: 1; max-width: 100%; max-height: 100%; @@ -62,6 +62,10 @@ $border-radius: 0.25rem; padding-right: 10px; /* To equalize with empty whitespace from image on left */ } +.trim-button > img { + width: 1.5rem; +} + .trim-button-active { filter: hue-rotate(155deg); /* @todo replace blue -> red with real submit icon */ } @@ -74,7 +78,7 @@ $border-radius: 0.25rem; .effect-button { flex-basis: 150px; - color: #575e75; /* @todo discuss the multiple font colors with Carl, move to variable */ + color: $text-primary; } .effect-button + .effect-button { diff --git a/src/components/sprite-info/icon--hide.svg b/src/components/sprite-info/icon--hide.svg index 9463538707630dcc89773e8e0f9c5800a22a2a64..2fd66b8300966bd93aae01b51358b760d5815ec4 100644 Binary files a/src/components/sprite-info/icon--hide.svg and b/src/components/sprite-info/icon--hide.svg differ diff --git a/src/components/sprite-info/icon--show.svg b/src/components/sprite-info/icon--show.svg index db06b704809d81c056f3873dcb1583ffc82afb9a..fe54ed568913266746b3086cc649164218523a3e 100644 Binary files a/src/components/sprite-info/icon--show.svg and b/src/components/sprite-info/icon--show.svg differ diff --git a/src/components/sprite-info/icon--x.svg b/src/components/sprite-info/icon--x.svg index 4d3cbc3a1cd7c5ddd964416e8d4fe750d1b924f2..57b1852f587167e33499b395c083939c4ae21059 100644 Binary files a/src/components/sprite-info/icon--x.svg and b/src/components/sprite-info/icon--x.svg differ diff --git a/src/components/sprite-info/icon--y.svg b/src/components/sprite-info/icon--y.svg index 5fe7df308aa6b56b20450b97093ca767bf5c08e2..f8eab05ef0a6b673869d6db5ffaa321029f98d9d 100644 Binary files a/src/components/sprite-info/icon--y.svg and b/src/components/sprite-info/icon--y.svg differ diff --git a/src/components/sprite-info/sprite-info.css b/src/components/sprite-info/sprite-info.css index e6a758d1c44936e7f3a35a9faa7a2cda6ee6a2a8..200aedaf79f13627abf74a4c9378ccb0f60eb300 100644 --- a/src/components/sprite-info/sprite-info.css +++ b/src/components/sprite-info/sprite-info.css @@ -3,10 +3,10 @@ .sprite-info { height: $sprite-info-height; - padding: $space; + padding: 0.75rem; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; background-color: white; - color: #575e75; + color: $text-primary; border-top-left-radius: $space; border-top-right-radius: $space; border-bottom: 1px solid #eaeaea; @@ -17,7 +17,9 @@ justify-content: space-between; } -.row-primary { margin-bottom: $space; } +.row-primary { + margin-bottom: 0.5rem; +} .label { opacity: 0.8; } @@ -29,15 +31,10 @@ .icon-wrapper { display: inline-block; - - /* - content-box is normally the browser's default. - We're overriding the global, which we set to border-box - */ box-sizing: content-box; - width: 1.25rem; - height: 1.25rem; - padding: calc($space / 2); + width: 1rem; + height: 1rem; + padding: 0.5rem; outline: none; user-select: none; } @@ -48,9 +45,16 @@ } /* @todo: refactor radio divs to input */ -.radio { opacity: 0.4; cursor: pointer; } -.radio.is-active { opacity: 1; } -.radio.is-disabled { cursor: default; } +.radio { + filter: saturate(0); + cursor: pointer; +} +.radio.is-active { + filter: none; +} +.radio.is-disabled { + cursor: default; +} .radio-left { border: 1px solid $form-border; diff --git a/src/components/sprite-info/sprite-info.jsx b/src/components/sprite-info/sprite-info.jsx index 8b3f212d99dfa870db88c6338f4efd09cfe0a643..f9bbb64e32748af1f955db724d8dc113caea8648 100644 --- a/src/components/sprite-info/sprite-info.jsx +++ b/src/components/sprite-info/sprite-info.jsx @@ -97,49 +97,50 @@ class SpriteInfo extends React.Component { <div className={classNames(styles.row, styles.rowSecondary)}> <div className={styles.group}> - <Label - secondary - text="Show" - > - <div> - <div - className={classNames( - styles.radio, - styles.radioLeft, - styles.iconWrapper, - { - [styles.isActive]: this.props.visible && !this.props.disabled, - [styles.isDisabled]: this.props.disabled - } - )} - tabIndex="4" - onClick={this.props.onClickVisible} - > - <img - className={styles.icon} - src={showIcon} - /> - </div> - <div - className={classNames( - styles.radio, - styles.radioRight, - styles.iconWrapper, - { - [styles.isActive]: !this.props.visible && !this.props.disabled, - [styles.isDisabled]: this.props.disabled - } - )} - tabIndex="4" - onClick={this.props.onClickNotVisible} - > - <img - className={styles.icon} - src={hideIcon} - /> - </div> + <MediaQuery minWidth={layout.fullSizeMinWidth}> + <Label + secondary + text="Show" + /> + </MediaQuery> + <div> + <div + className={classNames( + styles.radio, + styles.radioLeft, + styles.iconWrapper, + { + [styles.isActive]: this.props.visible && !this.props.disabled, + [styles.isDisabled]: this.props.disabled + } + )} + tabIndex="4" + onClick={this.props.onClickVisible} + > + <img + className={styles.icon} + src={showIcon} + /> </div> - </Label> + <div + className={classNames( + styles.radio, + styles.radioRight, + styles.iconWrapper, + { + [styles.isActive]: !this.props.visible && !this.props.disabled, + [styles.isDisabled]: this.props.disabled + } + )} + tabIndex="4" + onClick={this.props.onClickNotVisible} + > + <img + className={styles.icon} + src={hideIcon} + /> + </div> + </div> </div> <div className={styles.group}> <Label diff --git a/src/components/sprite-selector-item/sprite-selector-item.css b/src/components/sprite-selector-item/sprite-selector-item.css index faf7c36125223cbd400d2ae1c64fb40d8a58d51b..60f8393565b8d2e4068a005dd5bc65f4e00dbef4 100644 --- a/src/components/sprite-selector-item/sprite-selector-item.css +++ b/src/components/sprite-selector-item/sprite-selector-item.css @@ -1,4 +1,5 @@ @import "../../css/units.css"; +@import "../../css/colors.css"; /* @todo: refactor this class name, and component: `sprite-selector` to `sprite` */ .sprite-selector-item { @@ -17,22 +18,16 @@ border-radius: $space; text-align: center; cursor: pointer; -} - -.sprite-selector-item:hover { - border-width: 2px; - border-color: #1dacf4; transition: 0.25s ease-out; } -/* @todo: refactor out descendent selectors into regular classes */ .sprite-selector-item.is-selected { - border-width: 2px; - border-color: #1dacf4; - transition: 0.25s ease-out; + border: 2px solid $motion-primary; + box-shadow: 0px 0px 0px 3px $motion-transparent; } -.sprite-selector-item.is-selected .info-button { - display: block; + +.sprite-selector-item:hover { + border: 2px solid $motion-primary; } .sprite-image { @@ -44,7 +39,7 @@ font-size: 0.625rem; margin: 0.15rem; user-select: none; - + /* For truncating overflowing text gracefully Min-width is for a bug: https://css-tricks.com/flexbox-truncated-text diff --git a/src/components/sprite-selector-item/sprite-selector-item.jsx b/src/components/sprite-selector-item/sprite-selector-item.jsx index 37e80217126cd251f3e1b09ca6987659f9cdae71..2db6a7feacdb0b0b04babe8e07aa7bea9ecb2990 100644 --- a/src/components/sprite-selector-item/sprite-selector-item.jsx +++ b/src/components/sprite-selector-item/sprite-selector-item.jsx @@ -28,7 +28,7 @@ const SpriteSelectorItem = props => ( size={CloseButton.SIZE_SMALL} onClick={props.onDeleteButtonClick} /> - ) : null } + ) : null } {props.costumeURL ? ( <CostumeCanvas className={styles.spriteImage} @@ -36,7 +36,7 @@ const SpriteSelectorItem = props => ( url={props.costumeURL} width={32} /> - ) : null} + ) : null} <div className={styles.spriteName}>{props.name}</div> <ContextMenu id={`${props.name}-${contextMenuId++}`}> <MenuItem onClick={props.onDeleteButtonClick}> diff --git a/src/components/sprite-selector/sprite-selector.css b/src/components/sprite-selector/sprite-selector.css index 496d19588341fa4b7ae87bef8389b171d1efb9fb..8c1e2315a4dd79294d91585154301fbce4ffe820 100644 --- a/src/components/sprite-selector/sprite-selector.css +++ b/src/components/sprite-selector/sprite-selector.css @@ -30,7 +30,7 @@ */ box-sizing: border-box; width: calc((100% / $sprites-per-row ) - $space); - + min-width: 4rem; min-height: 4rem; /* @todo: calc height same as width */ margin: calc($space / 2); } @@ -64,6 +64,6 @@ font-size: 0.55rem; font-weight: bold; position: absolute; - bottom: 0.5rem; - right: 0.5rem; + bottom: 0.75rem; + right: 1rem; } diff --git a/src/components/stage-selector/stage-selector.css b/src/components/stage-selector/stage-selector.css index f3f01a5eb6e86a2959673ebb32653c1792a851e9..73df5305ff6823fbfbd70e665bfb1e183f98af1a 100644 --- a/src/components/stage-selector/stage-selector.css +++ b/src/components/stage-selector/stage-selector.css @@ -1,19 +1,34 @@ @import "../../css/units.css"; +@import "../../css/colors.css"; -$header-height: 2.5rem; /* @todo: half the SpriteInfo area header? */ +$header-height: calc($stage-menu-height - 2px); .stage-selector { - position: relative; /* for the child element border */ - height: 100%; + display: flex; + flex-direction: column; + align-items: center; + position: relative; /* For the add backdrop button */ + flex-grow: 1; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - background-color: white; - color: #575e75; + background-color: #fff; + color: $text-primary; border-top-left-radius: $space; border-top-right-radius: $space; border-color: #dbdbdb; border-width: 1px; border-style: solid; border-bottom: 0; + cursor: pointer; + transition: border-color 0.25s ease-out, box-shadow 0.25s ease-out; +} + +.stage-selector.is-selected { + border-color: $motion-primary; + box-shadow: 0px 0px 0px 3px $motion-transparent; +} + +.stage-selector:hover { + border-color: $motion-primary; } .header { @@ -22,82 +37,45 @@ $header-height: 2.5rem; /* @todo: half the SpriteInfo area header? */ align-items: center; justify-content: center; height: $header-height; - padding: 0.5rem 0.5rem 0.5rem 0.25rem; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; background-color: white; - color: #575e75; + color: $text-primary; border-top-left-radius: $space; border-top-right-radius: $space; border-bottom: 1px solid #eaeaea; + width: 100%; } .header-title { - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 0.625rem; font-weight: bold; - color: #575e75; + color: $text-primary; /* @todo: make this a mixin for all UI text labels */ user-select: none; - cursor: default; -} - -.body { - justify-content: space-around; - padding: $space; - height: calc(100% - $header-height); - background-color: #f9f9f9; } .count { - margin: 0 0 0.3rem 0; - padding: 0.15rem 0.5rem; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + padding: 0.3rem 0.75rem; font-size: 0.625rem; - font-weight: bold; - color: #575e75; - background: #ededed; + color: $text-primary; + background: white; + border: 1px solid #eaeaea; border-radius: 0.25rem; user-select: none; } .label { - margin: 0.4rem 0 0.25rem; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - font-size: 0.5rem; - font-weight: bold; - color: #575e75; + margin: 0.75rem 0 0.25rem; + font-size: 0.6rem; + color: $text-primary; user-select: none; } -$border-width: 2px; - -.flex-wrapper { - display: flex; - flex-direction: column; /* makes rows */ - align-items: center; - overflow: hidden; - background-color: white; - border-radius: calc($space / 2); - border-width: $border-width; - border-style: solid; - border-color: #e9eef2; - cursor: pointer; - transition: border-color 0.1s ease-out; -} - -.flex-wrapper:hover { - border-color: #1dacf4; -} - -.flex-wrapper.is-selected { - border-color: #1dacf4; -} - .costume-canvas { display: block; width: 100%; user-select: none; + border-bottom: 1px solid #eaeaea; } .add-button { @@ -105,6 +83,6 @@ $border-width: 2px; font-weight: bold; text-align: center; position: absolute; - bottom: 0.5rem; + bottom: 0.75rem; left: 0.25rem; } diff --git a/src/components/stage-selector/stage-selector.jsx b/src/components/stage-selector/stage-selector.jsx index ec5fbb36d2d6d387c0477a1aa55d8a0ccc0a1459..93002f9efb461e2b141104024711df8f5bc0b625 100644 --- a/src/components/stage-selector/stage-selector.jsx +++ b/src/components/stage-selector/stage-selector.jsx @@ -28,38 +28,37 @@ const StageSelector = props => { } = props; return ( <Box - className={styles.stageSelector} + className={classNames(styles.stageSelector, { + [styles.isSelected]: selected + })} onClick={onClick} {...componentProps} > <div className={styles.header}> <div className={styles.headerTitle}>Stage</div> </div> - <div className={styles.body}> - <div - className={classNames({ - [styles.flexWrapper]: true, - [styles.isSelected]: selected - })} - > - {url ? ( - <CostumeCanvas - className={styles.costumeCanvas} - height={42} - url={url} - width={56} - /> - ) : null} - <div className={styles.label}>Backdrops</div> - <div className={styles.count}>{backdropCount}</div> - </div> - <IconButton - className={styles.addButton} - img={backdropIcon} - title={addBackdropMessage} - onClick={onNewBackdropClick} + {url ? ( + <CostumeCanvas + className={styles.costumeCanvas} + height={42} + url={url} + width={56} + /> + ) : null} + <div className={styles.label}> + <FormattedMessage + defaultMessage="Backdrops" + description="Label for the backdrops in the stage selector" + id="stageSelector.backdrops" /> </div> + <div className={styles.count}>{backdropCount}</div> + <IconButton + className={styles.addButton} + img={backdropIcon} + title={addBackdropMessage} + onClick={onNewBackdropClick} + /> </Box> ); }; diff --git a/src/components/stop-all/stop-all.css b/src/components/stop-all/stop-all.css index 95a63377110218a2977663572866915e599a537a..99ebee89284ebd481597ef86874083352b2dc352 100644 --- a/src/components/stop-all/stop-all.css +++ b/src/components/stop-all/stop-all.css @@ -1,13 +1,22 @@ .stop-all { - width: 1.1rem; - height: 1.1rem; - opacity: 0.5; + box-sizing: content-box; + width: 1.25rem; + height: 1.25rem; + padding: 0.375rem; + border-radius: 0.25rem; user-select: none; cursor: pointer; - transition: opacity 0.2s ease-out; /* @todo: standardize with var */ + transition: 0.2s ease-out; +} + +.stop-all { + opacity: 0.5; } -.stop-all.is-active, .stop-all:hover { + transform: scale(1.2); +} + +.stop-all.is-active { opacity: 1; } diff --git a/src/components/stop-all/stop-all.jsx b/src/components/stop-all/stop-all.jsx index aba3ed42415c51456610ea5a892ddaccd0927921..f2916f605010441b3f7c5985ff5045dae1d5861f 100644 --- a/src/components/stop-all/stop-all.jsx +++ b/src/components/stop-all/stop-all.jsx @@ -8,16 +8,20 @@ import styles from './stop-all.css'; const StopAllComponent = function (props) { const { active, + className, onClick, title, ...componentProps } = props; return ( <img - className={classNames({ - [styles.stopAll]: true, - [styles.isActive]: active - })} + className={classNames( + className, + styles.stopAll, + { + [styles.isActive]: active + } + )} src={stopAllIcon} title={title} onClick={onClick} @@ -28,6 +32,7 @@ const StopAllComponent = function (props) { StopAllComponent.propTypes = { active: PropTypes.bool, + className: PropTypes.string, onClick: PropTypes.func.isRequired, title: PropTypes.string }; diff --git a/src/components/target-pane/target-pane.css b/src/components/target-pane/target-pane.css index 380200ff52503aef919dad684d0d75e1989e06f4..404283d19fd8c77bafc1e9f0b307628168d1cfc4 100644 --- a/src/components/target-pane/target-pane.css +++ b/src/components/target-pane/target-pane.css @@ -4,22 +4,12 @@ /* Makes columns for the sprite library selector + and the stage selector */ display: flex; flex-direction: row; - - height: 100%; + flex-grow: 1; } .stage-selector-wrapper { + display: flex; flex-basis: 72px; flex-shrink: 0; margin-left: calc($space / 2); } - -.add-button-wrapper { - position: absolute; - z-index: 1; - bottom: 0.5rem; - border: 0; - transition: all 0.15s ease-out; /* @todo: standardize with var */ - cursor: pointer; - user-select: none; -} diff --git a/src/components/turbo-mode/icon--turbo.svg b/src/components/turbo-mode/icon--turbo.svg new file mode 100644 index 0000000000000000000000000000000000000000..64fd1665fc03cb694262fc1a67b1adc958e29f4b Binary files /dev/null and b/src/components/turbo-mode/icon--turbo.svg differ diff --git a/src/components/turbo-mode/turbo-mode.css b/src/components/turbo-mode/turbo-mode.css new file mode 100644 index 0000000000000000000000000000000000000000..d12699cee0764a66a5e60e86c7ec259239704571 --- /dev/null +++ b/src/components/turbo-mode/turbo-mode.css @@ -0,0 +1,20 @@ +@import "../../css/colors.css"; + +.turbo-container { + display: flex; + align-items: center; + padding: 0.25rem; + user-select: none; +} + +.turbo-icon { + margin: 0.25rem; +} + +.turbo-label { + font-size: 0.625rem; + font-weight: bold; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + color: $control-primary; + white-space: nowrap; +} diff --git a/src/components/turbo-mode/turbo-mode.jsx b/src/components/turbo-mode/turbo-mode.jsx new file mode 100644 index 0000000000000000000000000000000000000000..724af24d1e1527c2acb79de0df0083d3ef1de4e7 --- /dev/null +++ b/src/components/turbo-mode/turbo-mode.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import {FormattedMessage} from 'react-intl'; + +import turboIcon from './icon--turbo.svg'; + +import styles from './turbo-mode.css'; + +const TurboMode = () => ( + <div className={styles.turboContainer}> + <img + className={styles.turboIcon} + src={turboIcon} + /> + <div className={styles.turboLabel}> + <FormattedMessage + defaultMessage="Turbo Mode" + description="Label indicating turbo mode is active" + id="controls.turboMode" + /> + </div> + </div> +); + +export default TurboMode; diff --git a/src/containers/audio-trimmer.jsx b/src/containers/audio-trimmer.jsx index 7d7c1e9f41ffec43082fbea67ca6174e8f6b4f0f..f4ba1be3cfb8376ee45c28ec1026b17fee56c7bf 100644 --- a/src/containers/audio-trimmer.jsx +++ b/src/containers/audio-trimmer.jsx @@ -2,6 +2,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import bindAll from 'lodash.bindall'; import AudioTrimmerComponent from '../components/audio-trimmer/audio-trimmer.jsx'; +import {getEventXY} from '../lib/touch-utils'; class AudioTrimmer extends React.Component { constructor (props) { @@ -18,14 +19,14 @@ class AudioTrimmer extends React.Component { } handleTrimStartMouseMove (e) { const containerSize = this.containerElement.getBoundingClientRect().width; - const dx = (e.clientX - this.initialX) / containerSize; + const dx = (getEventXY(e).x - this.initialX) / containerSize; const newTrim = Math.max(0, Math.min(this.props.trimEnd, this.initialTrim + dx)); this.props.onSetTrimStart(newTrim); e.preventDefault(); } handleTrimEndMouseMove (e) { const containerSize = this.containerElement.getBoundingClientRect().width; - const dx = (e.clientX - this.initialX) / containerSize; + const dx = (getEventXY(e).x - this.initialX) / containerSize; const newTrim = Math.min(1, Math.max(this.props.trimStart, this.initialTrim + dx)); this.props.onSetTrimEnd(newTrim); e.preventDefault(); @@ -33,22 +34,30 @@ class AudioTrimmer extends React.Component { handleTrimStartMouseUp () { window.removeEventListener('mousemove', this.handleTrimStartMouseMove); window.removeEventListener('mouseup', this.handleTrimStartMouseUp); + window.removeEventListener('touchmove', this.handleTrimStartMouseMove); + window.removeEventListener('touchend', this.handleTrimStartMouseUp); } handleTrimEndMouseUp () { window.removeEventListener('mousemove', this.handleTrimEndMouseMove); window.removeEventListener('mouseup', this.handleTrimEndMouseUp); + window.removeEventListener('touchmove', this.handleTrimEndMouseMove); + window.removeEventListener('touchend', this.handleTrimEndMouseUp); } handleTrimStartMouseDown (e) { - this.initialX = e.clientX; + this.initialX = getEventXY(e).x; this.initialTrim = this.props.trimStart; window.addEventListener('mousemove', this.handleTrimStartMouseMove); window.addEventListener('mouseup', this.handleTrimStartMouseUp); + window.addEventListener('touchmove', this.handleTrimStartMouseMove); + window.addEventListener('touchend', this.handleTrimStartMouseUp); } handleTrimEndMouseDown (e) { - this.initialX = e.clientX; + this.initialX = getEventXY(e).x; this.initialTrim = this.props.trimEnd; window.addEventListener('mousemove', this.handleTrimEndMouseMove); window.addEventListener('mouseup', this.handleTrimEndMouseUp); + window.addEventListener('touchmove', this.handleTrimEndMouseMove); + window.addEventListener('touchend', this.handleTrimEndMouseUp); } storeRef (el) { this.containerElement = el; diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 86d9108973e67e3f294166e0c86a4b39badae8a1..63a76276585a197941462999f26c911e31526ebe 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -208,7 +208,7 @@ class Blocks extends React.Component { } Blocks.propTypes = { - isVisible: PropTypes.bool.isRequired, + isVisible: PropTypes.bool, options: PropTypes.shape({ media: PropTypes.string, zoom: PropTypes.shape({ @@ -260,6 +260,7 @@ Blocks.defaultOptions = { }; Blocks.defaultProps = { + isVisible: true, options: Blocks.defaultOptions }; diff --git a/src/containers/controls.jsx b/src/containers/controls.jsx new file mode 100644 index 0000000000000000000000000000000000000000..18dd44e5ea6c41204f706f665c88cfbb1c093b11 --- /dev/null +++ b/src/containers/controls.jsx @@ -0,0 +1,70 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import VM from 'scratch-vm'; + +import ControlsComponent from '../components/controls/controls.jsx'; + +class Controls extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleGreenFlagClick', + 'handleStopAllClick', + 'onProjectRunStart', + 'onProjectRunStop' + ]); + this.state = { + projectRunning: false, + turbo: false + }; + } + componentDidMount () { + this.props.vm.addListener('PROJECT_RUN_START', this.onProjectRunStart); + this.props.vm.addListener('PROJECT_RUN_STOP', this.onProjectRunStop); + } + componentWillUnmount () { + this.props.vm.removeListener('PROJECT_RUN_START', this.onProjectRunStart); + this.props.vm.removeListener('PROJECT_RUN_STOP', this.onProjectRunStop); + } + onProjectRunStart () { + this.setState({projectRunning: true}); + } + onProjectRunStop () { + this.setState({projectRunning: false}); + } + handleGreenFlagClick (e) { + e.preventDefault(); + if (e.shiftKey) { + this.setState({turbo: !this.state.turbo}); + this.props.vm.setTurboMode(!this.state.turbo); + } else { + this.props.vm.greenFlag(); + } + } + handleStopAllClick (e) { + e.preventDefault(); + this.props.vm.stopAll(); + } + render () { + const { + vm, // eslint-disable-line no-unused-vars + ...props + } = this.props; + return ( + <ControlsComponent + {...props} + active={this.state.projectRunning} + turbo={this.state.turbo} + onGreenFlagClick={this.handleGreenFlagClick} + onStopAllClick={this.handleStopAllClick} + /> + ); + } +} + +Controls.propTypes = { + vm: PropTypes.instanceOf(VM) +}; + +export default Controls; diff --git a/src/containers/green-flag.jsx b/src/containers/green-flag.jsx index b1f6acabf620c7b54f431f1f1e0e65821a580be5..a725b7e1ba6d198f69cc36da32c676ac9e350d12 100644 --- a/src/containers/green-flag.jsx +++ b/src/containers/green-flag.jsx @@ -11,24 +11,18 @@ class GreenFlag extends React.Component { super(props); bindAll(this, [ 'handleClick', - 'handleKeyDown', - 'handleKeyUp', 'onProjectRunStart', 'onProjectRunStop' ]); - this.state = {projectRunning: false, shiftKeyDown: false}; + this.state = {projectRunning: false}; } componentDidMount () { this.props.vm.addListener('PROJECT_RUN_START', this.onProjectRunStart); this.props.vm.addListener('PROJECT_RUN_STOP', this.onProjectRunStop); - document.addEventListener('keydown', this.handleKeyDown); - document.addEventListener('keyup', this.handleKeyUp); } componentWillUnmount () { this.props.vm.removeListener('PROJECT_RUN_START', this.onProjectRunStart); this.props.vm.removeListener('PROJECT_RUN_STOP', this.onProjectRunStop); - document.removeEventListener('keydown', this.handleKeyDown); - document.removeEventListener('keyup', this.handleKeyUp); } onProjectRunStart () { this.setState({projectRunning: true}); @@ -36,15 +30,9 @@ class GreenFlag extends React.Component { onProjectRunStop () { this.setState({projectRunning: false}); } - handleKeyDown (e) { - this.setState({shiftKeyDown: e.shiftKey}); - } - handleKeyUp (e) { - this.setState({shiftKeyDown: e.shiftKey}); - } handleClick (e) { e.preventDefault(); - if (this.state.shiftKeyDown) { + if (e.shiftKey) { this.props.vm.setTurboMode(!this.props.vm.runtime.turboMode); } else { this.props.vm.greenFlag(); diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index 87f195aab83d5e1a70b7d33258babfeee7dd14a3..c41f7a9a97a8026ae96742354fcd6458a0135e08 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -1,3 +1,4 @@ +import AudioEngine from 'scratch-audio'; import PropTypes from 'prop-types'; import React from 'react'; import VM from 'scratch-vm'; @@ -16,6 +17,8 @@ class GUI extends React.Component { this.state = {tabIndex: 0}; } componentDidMount () { + this.audioEngine = new AudioEngine(); + this.props.vm.attachAudioEngine(this.audioEngine); this.props.vm.loadProject(this.props.projectData); this.props.vm.setCompatibilityMode(true); this.props.vm.start(); @@ -33,6 +36,7 @@ class GUI extends React.Component { } render () { const { + children, projectData, // eslint-disable-line no-unused-vars vm, ...componentProps @@ -43,7 +47,9 @@ class GUI extends React.Component { vm={vm} onTabSelect={this.handleTabSelect} {...componentProps} - /> + > + {children} + </GUIComponent> ); } } diff --git a/src/containers/sound-library.jsx b/src/containers/sound-library.jsx index fb98c070ccd616cfe0b88f46c8ee9a5e6e6e24c9..8fe3f97bd093365182507796afb5dc2a67f7445c 100644 --- a/src/containers/sound-library.jsx +++ b/src/containers/sound-library.jsx @@ -31,18 +31,19 @@ class SoundLibrary extends React.PureComponent { const idParts = md5ext.split('.'); const md5 = idParts[0]; const vm = this.props.vm; - vm.runtime.storage.load(vm.runtime.storage.AssetType.Sound, md5).then(soundAsset => { - const sound = { - md5: md5ext, - name: soundItem.name, - format: soundItem.format, - data: soundAsset.data - }; - return this.audioEngine.decodeSound(sound); - }) - .then(soundId => { - this.player.playSound(soundId); - }); + vm.runtime.storage.load(vm.runtime.storage.AssetType.Sound, md5) + .then(soundAsset => { + const sound = { + md5: md5ext, + name: soundItem.name, + format: soundItem.format, + data: soundAsset.data + }; + return this.audioEngine.decodeSound(sound); + }) + .then(soundId => { + this.player.playSound(soundId); + }); } handleItemMouseLeave () { this.player.stopAllSounds(); diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx index d9ab05f12966d701d898142b9f7a5a4855af9d15..1b5b2ec9cc4782fdb10a06e9099db31d7fcec64e 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -2,8 +2,8 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; import Renderer from 'scratch-render'; -import AudioEngine from 'scratch-audio'; import VM from 'scratch-vm'; +import {getEventXY} from '../lib/touch-utils'; import StageComponent from '../components/stage/stage.jsx'; @@ -37,8 +37,6 @@ class Stage extends React.Component { this.updateRect(); this.renderer = new Renderer(this.canvas); this.props.vm.attachRenderer(this.renderer); - this.audioEngine = new AudioEngine(); - this.props.vm.attachAudioEngine(this.audioEngine); } shouldComponentUpdate (nextProps) { return this.props.width !== nextProps.width || this.props.height !== nextProps.height; @@ -50,12 +48,18 @@ class Stage extends React.Component { attachMouseEvents (canvas) { document.addEventListener('mousemove', this.onMouseMove); document.addEventListener('mouseup', this.onMouseUp); + document.addEventListener('touchmove', this.onMouseMove); + document.addEventListener('touchend', this.onMouseUp); canvas.addEventListener('mousedown', this.onMouseDown); + canvas.addEventListener('touchstart', this.onMouseDown); } detachMouseEvents (canvas) { document.removeEventListener('mousemove', this.onMouseMove); document.removeEventListener('mouseup', this.onMouseUp); + document.removeEventListener('touchmove', this.onMouseMove); + document.removeEventListener('touchend', this.onMouseUp); canvas.removeEventListener('mousedown', this.onMouseDown); + canvas.removeEventListener('touchstart', this.onMouseDown); } attachRectEvents () { window.addEventListener('resize', this.updateRect); @@ -76,8 +80,9 @@ class Stage extends React.Component { ]; } handleDoubleClick (e) { + const {x, y} = getEventXY(e); // Set editing target from cursor position, if clicking on a sprite. - const mousePosition = [e.clientX - this.rect.left, e.clientY - this.rect.top]; + const mousePosition = [x - this.rect.left, y - this.rect.top]; const drawableId = this.renderer.pick(mousePosition[0], mousePosition[1]); if (drawableId === null) return; const targetId = this.props.vm.getTargetIdForDrawableId(drawableId); @@ -85,7 +90,8 @@ class Stage extends React.Component { this.props.vm.setEditingTarget(targetId); } onMouseMove (e) { - const mousePosition = [e.clientX - this.rect.left, e.clientY - this.rect.top]; + const {x, y} = getEventXY(e); + const mousePosition = [x - this.rect.left, y - this.rect.top]; if (this.state.mouseDownTimeoutId !== null) { this.cancelMouseDownTimeout(); if (this.state.mouseDown && !this.state.isDragging) { @@ -109,6 +115,7 @@ class Stage extends React.Component { this.props.vm.postIOData('mouse', coordinates); } onMouseUp (e) { + const {x, y} = getEventXY(e); this.cancelMouseDownTimeout(); this.setState({ mouseDown: false, @@ -119,8 +126,8 @@ class Stage extends React.Component { } else { const data = { isDown: false, - x: e.clientX - this.rect.left, - y: e.clientY - this.rect.top, + x: x - this.rect.left, + y: y - this.rect.top, canvasWidth: this.rect.width, canvasHeight: this.rect.height }; @@ -129,7 +136,8 @@ class Stage extends React.Component { } onMouseDown (e) { this.updateRect(); - const mousePosition = [e.clientX - this.rect.left, e.clientY - this.rect.top]; + const {x, y} = getEventXY(e); + const mousePosition = [x - this.rect.left, y - this.rect.top]; this.setState({ mouseDown: true, mouseDownPosition: mousePosition, @@ -146,7 +154,9 @@ class Stage extends React.Component { canvasHeight: this.rect.height }; this.props.vm.postIOData('mouse', data); - e.preventDefault(); + if (e.preventDefault) { + e.preventDefault(); + } } cancelMouseDownTimeout () { if (this.state.mouseDownTimeoutId !== null) { diff --git a/src/css/colors.css b/src/css/colors.css index 45258f13a7243bea9ddadb239654b3afe42473be..d524795cd5ece9550b0aee9767111e60d43c2f91 100644 --- a/src/css/colors.css +++ b/src/css/colors.css @@ -2,8 +2,11 @@ $ui-pane-border: #D9D9D9; $ui-pane-gray: #F9F9F9; $ui-background-blue: #e8edf1; +$text-primary: #575e75; + $motion-primary: #4C97FF; $motion-tertiary: #3373CC; +$motion-transparent: hsla(215, 100%, 65%, 0.20); $red-primary: #FF661A; $red-tertiary: #E64D00; @@ -11,4 +14,6 @@ $red-tertiary: #E64D00; $sound-primary: #CF63CF; $sound-tertiary: #A63FA6; +$control-primary: #FFAB19; + $form-border: #E9EEF2; diff --git a/src/css/units.css b/src/css/units.css index d951e3e1a874a4b34f22483d8d6cdc977d772e32..3de6174a2fe38b65148f7d99a5e459950e37160a 100644 --- a/src/css/units.css +++ b/src/css/units.css @@ -3,8 +3,8 @@ $space: 0.5rem; $sprites-per-row: 5; $menu-bar-height: 3rem; -$sprite-info-height: 5.25rem; /* @todo: SpriteInfo isn't explicitly set to this height yet */ -$stage-menu-height: 3rem; +$sprite-info-height: 6rem; +$stage-menu-height: 2.75rem; $library-header-height: 4.375rem; diff --git a/src/examples/blocks-only.css b/src/examples/blocks-only.css new file mode 100644 index 0000000000000000000000000000000000000000..0f3ac07f8504fd62cbf72ad1245a0b9ca0946581 --- /dev/null +++ b/src/examples/blocks-only.css @@ -0,0 +1,6 @@ +.controls { + position: absolute; + z-index: 2; + top: 10px; + right: 15px; +} diff --git a/src/examples/blocks-only.jsx b/src/examples/blocks-only.jsx new file mode 100644 index 0000000000000000000000000000000000000000..8d5159e825105374827e818ce7009e57587e87de --- /dev/null +++ b/src/examples/blocks-only.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import {connect} from 'react-redux'; + +import AppStateHOC from '../lib/app-state-hoc.jsx'; +import Controls from '../containers/controls.jsx'; +import Blocks from '../containers/blocks.jsx'; +import GUI from '../containers/gui.jsx'; +import ProjectLoaderHOC from '../lib/project-loader-hoc.jsx'; + +import styles from './blocks-only.css'; + +const mapStateToProps = state => ({vm: state.vm}); + +const VMBlocks = connect(mapStateToProps)(Blocks); +const VMControls = connect(mapStateToProps)(Controls); + +const BlocksOnly = props => ( + <GUI {...props}> + <VMBlocks + grow={1} + options={{ + media: `static/blocks-media/` + }} + /> + <VMControls className={styles.controls} /> + </GUI> +); + +const App = AppStateHOC(ProjectLoaderHOC(BlocksOnly)); + +const appTarget = document.createElement('div'); +document.body.appendChild(appTarget); + +ReactDOM.render(<App />, appTarget); diff --git a/src/examples/player.css b/src/examples/player.css new file mode 100644 index 0000000000000000000000000000000000000000..f5a3096c44d594f5bae77d04b8b9b5d2aaefa517 --- /dev/null +++ b/src/examples/player.css @@ -0,0 +1,4 @@ +body { + padding: 0; + margin: 0; +} diff --git a/src/examples/player.jsx b/src/examples/player.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ab50527cde1ae1d4be7788e7545397ff48b3700c --- /dev/null +++ b/src/examples/player.jsx @@ -0,0 +1,77 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import {connect} from 'react-redux'; + +import AppStateHOC from '../lib/app-state-hoc.jsx'; +import Controls from '../containers/controls.jsx'; +import Stage from '../containers/stage.jsx'; +import Box from '../components/box/box.jsx'; +import GUI from '../containers/gui.jsx'; +import ProjectLoaderHOC from '../lib/project-loader-hoc.jsx'; + +import './player.css'; + +const mapStateToProps = state => ({vm: state.vm}); + +const VMStage = connect(mapStateToProps)(Stage); +const VMControls = connect(mapStateToProps)(Controls); + +class Player extends React.Component { + constructor (props) { + super(props); + this.handleResize = this.handleResize.bind(this); + this.state = this.getWindowSize(); + } + componentDidMount () { + window.addEventListener('resize', this.handleResize); + } + componentWillUnmount () { + window.removeEventListener('resize', this.handleResize); + } + getWindowSize () { + return { + width: window.innerWidth, + height: window.innerHeight + }; + } + handleResize () { + this.setState(this.getWindowSize()); + } + render () { + let height = this.state.height - 40; + let width = height + (height / 3); + if (width > this.state.width) { + width = this.state.width; + height = width * .75; + } + return ( + <GUI + {...this.props} + style={{ + margin: '0 auto' + }} + width={width} + > + <Box height={40}> + <VMControls + style={{ + marginRight: 10, + height: 40 + }} + /> + </Box> + <VMStage + height={height} + width={width} + /> + </GUI> + ); + } +} + +const App = AppStateHOC(ProjectLoaderHOC(Player)); + +const appTarget = document.createElement('div'); +document.body.appendChild(appTarget); + +ReactDOM.render(<App />, appTarget); diff --git a/src/index.jsx b/src/index.jsx index 35c7610859e580e01d5e544d298bdb8f709b59bf..6d301f5168501f6b20252a6d0e034f509c5eea77 100644 --- a/src/index.jsx +++ b/src/index.jsx @@ -1,79 +1,16 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import {Provider} from 'react-redux'; -import {createStore, applyMiddleware, compose} from 'redux'; -import throttle from 'redux-throttle'; -import {intlInitialState, IntlProvider} from './reducers/intl.js'; +import AppStateHOC from './lib/app-state-hoc.jsx'; import GUI from './containers/gui.jsx'; -import log from './lib/log'; -import ProjectLoader from './lib/project-loader'; -import reducer from './reducers/gui'; +import ProjectLoaderHOC from './lib/project-loader-hoc.jsx'; import styles from './index.css'; -class App extends React.Component { - constructor (props) { - super(props); - this.fetchProjectId = this.fetchProjectId.bind(this); - this.updateProject = this.updateProject.bind(this); - this.state = { - projectId: null, - projectData: this.fetchProjectId().length ? null : JSON.stringify(ProjectLoader.DEFAULT_PROJECT_DATA) - }; - } - componentDidMount () { - window.addEventListener('hashchange', this.updateProject); - this.updateProject(); - } - componentWillUnmount () { - window.removeEventListener('hashchange', this.updateProject); - } - fetchProjectId () { - return window.location.hash.substring(1); - } - updateProject () { - const projectId = this.fetchProjectId(); - if (projectId !== this.state.projectId) { - if (projectId.length < 1) { - return this.setState({ - projectId: projectId, - projectData: JSON.stringify(ProjectLoader.DEFAULT_PROJECT_DATA) - }); - } - ProjectLoader.load(projectId, (err, body) => { - if (err) return log.error(err); - this.setState({projectData: body}); - }); - this.setState({projectId: projectId}); - } - } - render () { - if (this.state.projectData === null) return null; - return ( - <GUI - projectData={this.state.projectData} - /> - ); - } -} +const App = AppStateHOC(ProjectLoaderHOC(GUI)); const appTarget = document.createElement('div'); appTarget.className = styles.app; document.body.appendChild(appTarget); -const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; -const enhancer = composeEnhancers( - applyMiddleware( - throttle(300, {leading: true, trailing: true}) - ) -); -const store = createStore(reducer, intlInitialState, enhancer); - -ReactDOM.render(( - <Provider store={store}> - <IntlProvider> - <App /> - </IntlProvider> - </Provider> -), appTarget); +ReactDOM.render(<App />, appTarget); diff --git a/src/lib/app-state-hoc.jsx b/src/lib/app-state-hoc.jsx new file mode 100644 index 0000000000000000000000000000000000000000..0861f05291f258f8cf625947723da763f9f22d8c --- /dev/null +++ b/src/lib/app-state-hoc.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import {Provider} from 'react-redux'; +import {createStore, applyMiddleware, compose} from 'redux'; +import throttle from 'redux-throttle'; + +import {intlInitialState, IntlProvider} from '../reducers/intl.js'; +import reducer from '../reducers/gui'; + +const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; +const enhancer = composeEnhancers( + applyMiddleware( + throttle(300, {leading: true, trailing: true}) + ) +); +const store = createStore(reducer, intlInitialState, enhancer); + +/* + * Higher Order Component to provide redux state + * @param {React.Component} WrappedComponent - component to provide state for + * @returns {React.Component} component with redux and intl state provided + */ +const AppStateHOC = function (WrappedComponent) { + const AppStateWrapper = ({...props}) => ( + <Provider store={store}> + <IntlProvider> + <WrappedComponent {...props} /> + </IntlProvider> + </Provider> + ); + return AppStateWrapper; +}; + +export default AppStateHOC; diff --git a/src/lib/project-loader-hoc.jsx b/src/lib/project-loader-hoc.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5468e7a09b54c292c9e107183fff80550fb0c29d --- /dev/null +++ b/src/lib/project-loader-hoc.jsx @@ -0,0 +1,85 @@ +import React from 'react'; +import xhr from 'xhr'; + +import log from './log'; +import emptyProject from './empty-project.json'; + +class ProjectLoaderConstructor { + get DEFAULT_PROJECT_DATA () { + return emptyProject; + } + + load (id, callback) { + callback = callback || (err => log.error(err)); + xhr({ + uri: `https://projects.scratch.mit.edu/internalapi/project/${id}/get/` + }, (err, res, body) => { + if (err) return callback(err); + callback(null, body); + }); + } +} + +const ProjectLoader = new ProjectLoaderConstructor(); + +/* Higher Order Component to provide behavior for loading projects by id from + * the window's hash (#this part in the url) + * @param {React.Component} WrappedComponent component to receive projectData prop + * @returns {React.Component} component with project loading behavior + */ +const ProjectLoaderHOC = function (WrappedComponent) { + class ProjectLoaderComponent extends React.Component { + constructor (props) { + super(props); + this.fetchProjectId = this.fetchProjectId.bind(this); + this.updateProject = this.updateProject.bind(this); + this.state = { + projectId: null, + projectData: this.fetchProjectId().length ? null : JSON.stringify(ProjectLoader.DEFAULT_PROJECT_DATA) + }; + } + componentDidMount () { + window.addEventListener('hashchange', this.updateProject); + this.updateProject(); + } + componentWillUnmount () { + window.removeEventListener('hashchange', this.updateProject); + } + fetchProjectId () { + return window.location.hash.substring(1); + } + updateProject () { + const projectId = this.fetchProjectId(); + if (projectId !== this.state.projectId) { + if (projectId.length < 1) { + return this.setState({ + projectId: projectId, + projectData: JSON.stringify(ProjectLoader.DEFAULT_PROJECT_DATA) + }); + } + ProjectLoader.load(projectId, (err, body) => { + if (err) return log.error(err); + this.setState({projectData: body}); + }); + this.setState({projectId: projectId}); + } + } + render () { + if (!this.state.projectData) return null; + return ( + <WrappedComponent + projectData={this.state.projectData} + {...this.props} + /> + ); + } + } + + return ProjectLoaderComponent; +}; + + +export { + ProjectLoaderHOC as default, + ProjectLoader +}; diff --git a/src/lib/project-loader.js b/src/lib/project-loader.js deleted file mode 100644 index 05060732065e890b13e99e0cb9e4b90fe47dff31..0000000000000000000000000000000000000000 --- a/src/lib/project-loader.js +++ /dev/null @@ -1,23 +0,0 @@ -import xhr from 'xhr'; - -import log from './log'; -import emptyProject from './empty-project.json'; - -class ProjectLoader { - constructor () { - this.DEFAULT_PROJECT_DATA = ProjectLoader.DEFAULT_PROJECT_DATA; - } - load (id, callback) { - callback = callback || (err => log.error(err)); - xhr({ - uri: `https://projects.scratch.mit.edu/internalapi/project/${id}/get/` - }, (err, res, body) => { - if (err) return callback(err); - callback(null, body); - }); - } -} - -ProjectLoader.DEFAULT_PROJECT_DATA = emptyProject; - -export default new ProjectLoader(); diff --git a/src/lib/touch-utils.js b/src/lib/touch-utils.js new file mode 100644 index 0000000000000000000000000000000000000000..07c638d8d4653cfe1df99cad5a80e20aeb38bf87 --- /dev/null +++ b/src/lib/touch-utils.js @@ -0,0 +1,12 @@ +const getEventXY = e => { + if (e.touches && e.touches[0]) { + return {x: e.touches[0].clientX, y: e.touches[0].clientY}; + } else if (e.changedTouches && e.changedTouches[0]) { + return {x: e.changedTouches[0].clientX, y: e.changedTouches[0].clientY}; + } + return {x: e.clientX, y: e.clientY}; +}; + +export { + getEventXY +}; diff --git a/test/.eslintrc.js b/test/.eslintrc.js new file mode 100644 index 0000000000000000000000000000000000000000..ea7ef84f15c71d2b379ba0e22b700e82cd8a8893 --- /dev/null +++ b/test/.eslintrc.js @@ -0,0 +1,10 @@ +module.exports = { + extends: ['scratch/react', 'scratch/es6'], + env: { + browser: true, + jest: true + }, + rules: { + 'react/prop-types': 0 + } +}; diff --git a/test/__mocks__/audio-buffer-player.js b/test/__mocks__/audio-buffer-player.js index c36092be339f166b2376a5340c1e82abed6c9ca9..dabf72bc37d2ba6553530ada7dc4e9851f4f53a7 100644 --- a/test/__mocks__/audio-buffer-player.js +++ b/test/__mocks__/audio-buffer-player.js @@ -1,4 +1,3 @@ -/* eslint-env jest */ export default class MockAudioBufferPlayer { constructor (samples, sampleRate) { this.samples = samples; diff --git a/test/__mocks__/audio-effects.js b/test/__mocks__/audio-effects.js index 95c1bd8585ba3acd54b50e12666aac7b3084fa9c..291438f85fae419280e77e0c5b7dc79cba08b57c 100644 --- a/test/__mocks__/audio-effects.js +++ b/test/__mocks__/audio-effects.js @@ -1,4 +1,3 @@ -/* eslint-env jest */ export default class MockAudioEffects { static get effectTypes () { // @todo can this be imported from the real file? return { diff --git a/test/helpers/intl-helpers.js b/test/helpers/intl-helpers.jsx similarity index 54% rename from test/helpers/intl-helpers.js rename to test/helpers/intl-helpers.jsx index d658aeae0981adc70d8de4f57d3ec196a06f2df1..8c9a057b4e54a2d0cabd9c9fb93b8959d2bb65a0 100644 --- a/test/helpers/intl-helpers.js +++ b/test/helpers/intl-helpers.jsx @@ -10,33 +10,27 @@ import {mount, shallow} from 'enzyme'; const intlProvider = new IntlProvider({locale: 'en'}, {}); const {intl} = intlProvider.getChildContext(); -const nodeWithIntlProp = node => { - return React.cloneElement(node, {intl}); -}; +const nodeWithIntlProp = node => React.cloneElement(node, {intl}); -const shallowWithIntl = (node, {context} = {}) => { - return shallow( - nodeWithIntlProp(node), - { - context: Object.assign({}, context, {intl}) - } - ); -}; +const shallowWithIntl = (node, {context} = {}) => shallow( + nodeWithIntlProp(node), + { + context: Object.assign({}, context, {intl}) + } +); -const mountWithIntl = (node, {context, childContextTypes} = {}) => { - return mount( - nodeWithIntlProp(node), - { - context: Object.assign({}, context, {intl}), - childContextTypes: Object.assign({}, {intl: intlShape}, childContextTypes) - } - ); -}; +const mountWithIntl = (node, {context, childContextTypes} = {}) => mount( + nodeWithIntlProp(node), + { + context: Object.assign({}, context, {intl}), + childContextTypes: Object.assign({}, {intl: intlShape}, childContextTypes) + } +); // react-test-renderer component for use with snapshot testing -const componentWithIntl = (children, props = {locale: 'en'}) => { - return renderer.create(<IntlProvider {...props}>{children}</IntlProvider>); -}; +const componentWithIntl = (children, props = {locale: 'en'}) => renderer.create( + <IntlProvider {...props}>{children}</IntlProvider> +); export { componentWithIntl, diff --git a/test/helpers/selenium-helper.js b/test/helpers/selenium-helper.js new file mode 100644 index 0000000000000000000000000000000000000000..390740c3c2e9b7e8b3225388fe8141e55435d3ce --- /dev/null +++ b/test/helpers/selenium-helper.js @@ -0,0 +1,65 @@ +jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; // eslint-disable-line no-undef + +import bindAll from 'lodash.bindall'; +import webdriver from 'selenium-webdriver'; + +const {By, until} = webdriver; + +class SeleniumHelper { + constructor () { + bindAll(this, [ + 'clickText', + 'clickButton', + 'clickXpath', + 'findByXpath', + 'getDriver', + 'getLogs' + ]); + } + + getDriver () { + this.driver = new webdriver.Builder() + .forBrowser('chrome') + .build(); + return this.driver; + } + + findByXpath (xpath) { + return this.driver.wait(until.elementLocated(By.xpath(xpath), 5 * 1000)); + } + + clickXpath (xpath) { + return this.findByXpath(xpath).then(el => el.click()); + } + + clickText (text) { + return this.clickXpath(`//*[contains(text(), '${text}')]`); + } + + clickButton (text) { + return this.clickXpath(`//button[contains(text(), '${text}')]`); + } + + getLogs (whitelist) { + return this.driver.manage() + .logs() + .get('browser') + .then(entries => entries.filter(entry => { + const message = entry.message; + for (let i = 0; i < whitelist.length; i++) { + if (message.indexOf(whitelist[i]) !== -1) { + // eslint-disable-next-line no-console + console.warn(`Ignoring whitelisted error: ${whitelist[i]}`); + return false; + } else if (entry.level !== 'SEVERE') { + // eslint-disable-next-line no-console + console.warn(`Ignoring non-SEVERE entry: ${message}`); + return false; + } + } + return true; + })); + } +} + +export default SeleniumHelper; diff --git a/test/helpers/selenium-helpers.js b/test/helpers/selenium-helpers.js deleted file mode 100644 index 909f3a1553eaca14269f494216a1f55140aff87e..0000000000000000000000000000000000000000 --- a/test/helpers/selenium-helpers.js +++ /dev/null @@ -1,58 +0,0 @@ -/* eslint-env jest */ -jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; // eslint-disable-line no-undef - -import webdriver from 'selenium-webdriver'; - -const {By, until} = webdriver; - -const driver = new webdriver.Builder() - .forBrowser('chrome') - .build(); - -const findByXpath = (xpath) => { - return driver.wait(until.elementLocated(By.xpath(xpath), 5 * 1000)); -}; - -const clickXpath = (xpath) => { - return findByXpath(xpath).then(el => el.click()); -}; - -const clickText = (text) => { - return clickXpath(`//*[contains(text(), '${text}')]`); -}; - -const clickButton = (text) => { - return clickXpath(`//button[contains(text(), '${text}')]`); -}; - -const getLogs = (whitelist) => { - return driver.manage() - .logs() - .get('browser') - .then((entries) => { - return entries.filter((entry) => { - const message = entry.message; - for (let i = 0; i < whitelist.length; i++) { - if (message.indexOf(whitelist[i]) !== -1) { - // eslint-disable-next-line no-console - console.warn('Ignoring whitelisted error: ' + whitelist[i]); - return false; - } else if (entry.level !== 'SEVERE') { - // eslint-disable-next-line no-console - console.warn('Ignoring non-SEVERE entry: ' + message); - return false; - } - } - return true; - }); - }); -}; - -export { - clickText, - clickButton, - clickXpath, - driver, - findByXpath, - getLogs -}; diff --git a/test/integration/examples.test.js b/test/integration/examples.test.js new file mode 100644 index 0000000000000000000000000000000000000000..3299fb27c532df3838a82dd5cfa04528aa3c287b --- /dev/null +++ b/test/integration/examples.test.js @@ -0,0 +1,87 @@ +/* globals Promise */ + +import path from 'path'; +import SeleniumHelper from '../helpers/selenium-helper'; + +const { + clickButton, + clickText, + clickXpath, + findByXpath, + getDriver, + getLogs +} = new SeleniumHelper(); + +const errorWhitelist = [ + 'The play() request was interrupted by a call to pause()' +]; + +let driver; + +describe('player example', () => { + const uri = path.resolve(__dirname, '../../build/player.html'); + + beforeAll(() => { + driver = getDriver(); + }); + + afterAll(async () => { + await driver.quit(); + }); + + test('Load a project by ID', async () => { + const projectId = '96708228'; + await driver.get(`file://${uri}#${projectId}`); + await new Promise(resolve => setTimeout(resolve, 2000)); + await clickXpath('//img[@title="Go"]'); + await new Promise(resolve => setTimeout(resolve, 2000)); + await clickXpath('//img[@title="Stop"]'); + const logs = await getLogs(errorWhitelist); + await expect(logs).toEqual([]); + }); +}); + +describe('blocks example', () => { + const uri = path.resolve(__dirname, '../../build/blocks-only.html'); + + beforeAll(() => { + driver = getDriver(); + }); + + afterAll(async () => { + await driver.quit(); + }); + + test('Load a project by ID', async () => { + const projectId = '96708228'; + await driver.get(`file://${uri}#${projectId}`); + await new Promise(resolve => setTimeout(resolve, 2000)); + await clickXpath('//img[@title="Go"]'); + await new Promise(resolve => setTimeout(resolve, 2000)); + await clickXpath('//img[@title="Stop"]'); + const logs = await getLogs(errorWhitelist); + await expect(logs).toEqual([]); + }); + + test('Change categories', async () => { + await driver.get(`file://${uri}`); + await clickText('Looks'); + await clickText('Sound'); + await clickText('Pen'); + await clickText('Events'); + await clickText('Control'); + await clickText('Sensing'); + await clickText('Operators'); + await clickText('Data'); + await clickText('Create variable...'); + let el = await findByXpath("//input[@placeholder='']"); + await el.sendKeys('score'); + await clickButton('OK'); + await clickText('Create variable...'); + el = await findByXpath("//input[@placeholder='']"); + await el.sendKeys('second variable'); + await clickButton('OK'); + const logs = await getLogs(errorWhitelist); + await expect(logs).toEqual([]); + }); +}); diff --git a/test/integration/test.js b/test/integration/test.js index 7e3e60a651a6e7d64f06ad56c561058449432f20..39e8c6faec5a2451e08fb0133a0dd34462738fb4 100644 --- a/test/integration/test.js +++ b/test/integration/test.js @@ -1,15 +1,14 @@ -/* eslint-env jest */ -/* globals Promise */ - import path from 'path'; -import { +import SeleniumHelper from '../helpers/selenium-helper'; + +const { clickText, clickButton, clickXpath, - driver, findByXpath, + getDriver, getLogs -} from '../helpers/selenium-helpers'; +} = new SeleniumHelper(); const uri = path.resolve(__dirname, '../../build/index.html'); @@ -17,13 +16,19 @@ const errorWhitelist = [ 'The play() request was interrupted by a call to pause()' ]; +let driver; + describe('costumes, sounds and variables', () => { + beforeAll(() => { + driver = getDriver(); + }); + afterAll(async () => { await driver.quit(); }); test('Adding a costume', async () => { - await driver.get('file://' + uri); + await driver.get(`file://${uri}`); await clickText('Costumes'); await clickText('Add Costume'); const el = await findByXpath("//input[@placeholder='what are you looking for?']"); @@ -36,7 +41,7 @@ describe('costumes, sounds and variables', () => { }); test('Adding a sound', async () => { - await driver.get('file://' + uri); + await driver.get(`file://${uri}`); await clickText('Sounds'); await clickText('Add Sound'); const el = await findByXpath("//input[@placeholder='what are you looking for?']"); @@ -62,7 +67,7 @@ describe('costumes, sounds and variables', () => { test('Load a project by ID', async () => { const projectId = '96708228'; - await driver.get('file://' + uri + '#' + projectId); + await driver.get(`file://${uri}#${projectId}`); await new Promise(resolve => setTimeout(resolve, 2000)); await clickXpath('//img[@title="Go"]'); await new Promise(resolve => setTimeout(resolve, 2000)); @@ -72,7 +77,7 @@ describe('costumes, sounds and variables', () => { }); test('Creating variables', async () => { - await driver.get('file://' + uri); + await driver.get(`file://${uri}`); await clickText('Blocks'); await clickText('Data'); await clickText('Create variable...'); diff --git a/test/unit/components/__snapshots__/sound-editor.test.jsx.snap b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap index 644d330d0e5a099f59cfe4c527d93b5aa5913535..6e863223f1d8753ce674382f29db0153edf82b65 100644 --- a/test/unit/components/__snapshots__/sound-editor.test.jsx.snap +++ b/test/unit/components/__snapshots__/sound-editor.test.jsx.snap @@ -111,6 +111,7 @@ exports[`Sound Editor Component matches snapshot 1`] = ` <div className="" onMouseDown={[Function]} + onTouchStart={[Function]} style={ Object { "alignContent": undefined, @@ -231,6 +232,7 @@ exports[`Sound Editor Component matches snapshot 1`] = ` <div className="" onMouseDown={[Function]} + onTouchStart={[Function]} style={ Object { "alignContent": undefined, diff --git a/test/unit/components/button.test.jsx b/test/unit/components/button.test.jsx index 6f9b7447c4d48a0ce8a82f45fcf3ae6f394fc519..5445891ab1eabb9e9db44a0fc81366622474eb90 100644 --- a/test/unit/components/button.test.jsx +++ b/test/unit/components/button.test.jsx @@ -1,14 +1,13 @@ -/* eslint-env jest */ -import React from 'react'; // eslint-disable-line no-unused-vars +import React from 'react'; import {shallow} from 'enzyme'; -import ButtonComponent from '../../../src/components/button/button'; // eslint-disable-line no-unused-vars +import ButtonComponent from '../../../src/components/button/button'; import renderer from 'react-test-renderer'; describe('ButtonComponent', () => { test('matches snapshot', () => { const onClick = jest.fn(); const component = renderer.create( - <ButtonComponent onClick={onClick}/> + <ButtonComponent onClick={onClick} /> ); expect(component.toJSON()).toMatchSnapshot(); }); @@ -16,7 +15,7 @@ describe('ButtonComponent', () => { test('triggers callback when clicked', () => { const onClick = jest.fn(); const componentShallowWrapper = shallow( - <ButtonComponent onClick={onClick}/> + <ButtonComponent onClick={onClick} /> ); componentShallowWrapper.simulate('click'); expect(onClick).toHaveBeenCalled(); diff --git a/test/unit/components/controls.test.jsx b/test/unit/components/controls.test.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fcf22208d410c9815af9705b0941b40883fa8673 --- /dev/null +++ b/test/unit/components/controls.test.jsx @@ -0,0 +1,40 @@ +import React from 'react'; +import {shallowWithIntl} from '../../helpers/intl-helpers.jsx'; +import Controls from '../../../src/components/controls/controls'; +import TurboMode from '../../../src/components/turbo-mode/turbo-mode'; + +describe('Controls component', () => { + const defaultProps = () => ({ + active: false, + greenFlagTitle: 'Go', + onGreenFlagClick: jest.fn(), + onStopAllClick: jest.fn(), + stopAllTitle: 'Stop', + turbo: false + }); + + test('shows turbo mode when in turbo mode', () => { + const component = shallowWithIntl( + <Controls + {...defaultProps()} + /> + ); + expect(component.find(TurboMode).exists()).toEqual(false); + component.setProps({turbo: true}); + expect(component.find(TurboMode).exists()).toEqual(true); + }); + + test('triggers the right callbacks when clicked', () => { + const props = defaultProps(); + const component = shallowWithIntl( + <Controls + {...props} + /> + ); + component.find('[title="Go"]').simulate('click'); + expect(props.onGreenFlagClick).toHaveBeenCalled(); + + component.find('[title="Stop"]').simulate('click'); + expect(props.onStopAllClick).toHaveBeenCalled(); + }); +}); diff --git a/test/unit/components/icon-button.test.jsx b/test/unit/components/icon-button.test.jsx index fe2498a1a36b160759f2a29e1a3c98a5f320fb93..269dfad76fd30fec0a580baa21eca45302142edb 100644 --- a/test/unit/components/icon-button.test.jsx +++ b/test/unit/components/icon-button.test.jsx @@ -1,7 +1,6 @@ -/* eslint-env jest */ -import React from 'react'; // eslint-disable-line no-unused-vars +import React from 'react'; import {shallow} from 'enzyme'; -import IconButton from '../../../src/components/icon-button/icon-button'; // eslint-disable-line no-unused-vars +import IconButton from '../../../src/components/icon-button/icon-button'; import renderer from 'react-test-renderer'; describe('IconButtonComponent', () => { diff --git a/test/unit/components/sound-editor.test.jsx b/test/unit/components/sound-editor.test.jsx index 6c12f473060ed5b7167fe2b353563345b349c92d..387a53c1a73359e3870a288bf1d34adf83fb9985 100644 --- a/test/unit/components/sound-editor.test.jsx +++ b/test/unit/components/sound-editor.test.jsx @@ -1,7 +1,6 @@ -/* eslint-env jest */ -import React from 'react'; // eslint-disable-line no-unused-vars -import {mountWithIntl, componentWithIntl} from '../../helpers/intl-helpers'; -import SoundEditor from '../../../src/components/sound-editor/sound-editor'; // eslint-disable-line no-unused-vars +import React from 'react'; +import {mountWithIntl, componentWithIntl} from '../../helpers/intl-helpers.jsx'; +import SoundEditor from '../../../src/components/sound-editor/sound-editor'; describe('Sound Editor Component', () => { let props; @@ -38,31 +37,55 @@ describe('Sound Editor Component', () => { }); test('trim button appears when trims are null', () => { - const wrapper = mountWithIntl(<SoundEditor {...props} trimStart={null} trimEnd={null} />); + const wrapper = mountWithIntl( + <SoundEditor + {...props} + trimEnd={null} + trimStart={null} + /> + ); wrapper.find('button[title="Trim"]').simulate('click'); expect(props.onActivateTrim).toHaveBeenCalled(); }); test('save button appears when trims are not null', () => { - const wrapper = mountWithIntl(<SoundEditor {...props} trimStart={0.25} trimEnd={0.75} />); + const wrapper = mountWithIntl( + <SoundEditor + {...props} + trimEnd={0.75} + trimStart={0.25} + /> + ); wrapper.find('button[title="Save"]').simulate('click'); expect(props.onActivateTrim).toHaveBeenCalled(); }); test('play button appears when playhead is null', () => { - const wrapper = mountWithIntl(<SoundEditor {...props} playhead={null} />); + const wrapper = mountWithIntl( + <SoundEditor + {...props} + playhead={null} + /> + ); wrapper.find('button[title="Play"]').simulate('click'); expect(props.onPlay).toHaveBeenCalled(); }); test('stop button appears when playhead is not null', () => { - const wrapper = mountWithIntl(<SoundEditor {...props} playhead={0.5} />); + const wrapper = mountWithIntl( + <SoundEditor + {...props} + playhead={0.5} + /> + ); wrapper.find('button[title="Stop"]').simulate('click'); expect(props.onStop).toHaveBeenCalled(); }); test('submitting name calls the callback', () => { - const wrapper = mountWithIntl(<SoundEditor {...props} />); + const wrapper = mountWithIntl( + <SoundEditor {...props} /> + ); wrapper.find('input') .simulate('change', {target: {value: 'hello'}}) .simulate('blur'); @@ -70,7 +93,9 @@ describe('Sound Editor Component', () => { }); test('effect buttons call the correct callbacks', () => { - const wrapper = mountWithIntl(<SoundEditor {...props} />); + const wrapper = mountWithIntl( + <SoundEditor {...props} /> + ); wrapper.find('[children="Reverse"]').simulate('click'); expect(props.onReverse).toHaveBeenCalled(); @@ -95,17 +120,35 @@ describe('Sound Editor Component', () => { }); test('undo and redo buttons can be disabled by canUndo/canRedo', () => { - let wrapper = mountWithIntl(<SoundEditor {...props} canUndo={true} canRedo={false} />); + let wrapper = mountWithIntl( + <SoundEditor + {...props} + canUndo + canRedo={false} + /> + ); expect(wrapper.find('button[title="Undo"]').prop('disabled')).toBe(false); expect(wrapper.find('button[title="Redo"]').prop('disabled')).toBe(true); - wrapper = mountWithIntl(<SoundEditor {...props} canUndo={false} canRedo={true} />); + wrapper = mountWithIntl( + <SoundEditor + {...props} + canRedo + canUndo={false} + /> + ); expect(wrapper.find('button[title="Undo"]').prop('disabled')).toBe(true); expect(wrapper.find('button[title="Redo"]').prop('disabled')).toBe(false); }); test.skip('undo/redo buttons call the correct callback', () => { - let wrapper = mountWithIntl(<SoundEditor {...props} canUndo={true} canRedo={true} />); + const wrapper = mountWithIntl( + <SoundEditor + {...props} + canRedo + canUndo + /> + ); wrapper.find('button[title="Undo"]').simulate('click'); expect(props.onUndo).toHaveBeenCalled(); diff --git a/test/unit/components/sprite-selector-item.test.jsx b/test/unit/components/sprite-selector-item.test.jsx index fe0b1de5238b5076e9938da8303dfb714023212c..e1d7ac7bb73ae23fb10ff018035a097ef0137e06 100644 --- a/test/unit/components/sprite-selector-item.test.jsx +++ b/test/unit/components/sprite-selector-item.test.jsx @@ -1,10 +1,8 @@ -/* eslint-env jest */ -import React from 'react'; // eslint-disable-line no-unused-vars -import {mountWithIntl, shallowWithIntl, componentWithIntl} from '../../helpers/intl-helpers'; -// eslint-disable-next-line no-unused-vars +import React from 'react'; +import {mountWithIntl, shallowWithIntl, componentWithIntl} from '../../helpers/intl-helpers.jsx'; import SpriteSelectorItemComponent from '../../../src/components/sprite-selector-item/sprite-selector-item'; import CostumeCanvas from '../../../src/components/costume-canvas/costume-canvas'; -import CloseButton from '../../../src/components/close-button/close-button'; // eslint-disable-line no-unused-vars +import CloseButton from '../../../src/components/close-button/close-button'; describe('SpriteSelectorItemComponent', () => { let className; @@ -16,13 +14,16 @@ describe('SpriteSelectorItemComponent', () => { // Wrap this in a function so it gets test specific states and can be reused. const getComponent = function () { - return <SpriteSelectorItemComponent - className={className} - costumeURL={costumeURL} - name={name} - onClick={onClick} - onDeleteButtonClick={onDeleteButtonClick} - selected={selected}/>; + return ( + <SpriteSelectorItemComponent + className={className} + costumeURL={costumeURL} + name={name} + selected={selected} + onClick={onClick} + onDeleteButtonClick={onDeleteButtonClick} + /> + ); }; beforeEach(() => { diff --git a/test/unit/containers/green-flag.test.jsx b/test/unit/containers/green-flag.test.jsx index 24c3ae9c546e82d4b89476992bbcf1f159b0413f..4744ebaabd147318afdf430596f53e243b950700 100644 --- a/test/unit/containers/green-flag.test.jsx +++ b/test/unit/containers/green-flag.test.jsx @@ -1,7 +1,6 @@ -/* eslint-env jest */ -import React from 'react'; // eslint-disable-line no-unused-vars +import React from 'react'; import {shallow} from 'enzyme'; -import GreenFlag from '../../../src/containers/green-flag'; // eslint-disable-line no-unused-vars +import GreenFlag from '../../../src/containers/green-flag'; import renderer from 'react-test-renderer'; import VM from 'scratch-vm'; @@ -13,14 +12,20 @@ describe('GreenFlag Container', () => { test('renders active state', () => { const component = renderer.create( - <GreenFlag active={true} vm={vm}/> + <GreenFlag + active + vm={vm} + /> ); expect(component.toJSON()).toMatchSnapshot(); }); test('renders inactive state', () => { const component = renderer.create( - <GreenFlag active={false} vm={vm}/> + <GreenFlag + active={false} + vm={vm} + /> ); expect(component.toJSON()).toMatchSnapshot(); }); @@ -28,7 +33,11 @@ describe('GreenFlag Container', () => { test('triggers onClick when active', () => { const onClick = jest.fn(); const componentShallowWrapper = shallow( - <GreenFlag active={true} onClick={onClick} vm={vm}/> + <GreenFlag + active + vm={vm} + onClick={onClick} + /> ); componentShallowWrapper.simulate('click'); expect(onClick).toHaveBeenCalled(); diff --git a/test/unit/containers/sound-editor.test.jsx b/test/unit/containers/sound-editor.test.jsx index 527d3ed16c78b4cdc9d37b00cbfac724b302701b..187908176cb87db2573b71cb0c5e0b7111685976 100644 --- a/test/unit/containers/sound-editor.test.jsx +++ b/test/unit/containers/sound-editor.test.jsx @@ -1,12 +1,10 @@ -/* eslint-env jest */ -import React from 'react'; // eslint-disable-line no-unused-vars -import {mountWithIntl} from '../../helpers/intl-helpers'; +import React from 'react'; +import {mountWithIntl} from '../../helpers/intl-helpers.jsx'; import configureStore from 'redux-mock-store'; import mockAudioBufferPlayer from '../../__mocks__/audio-buffer-player.js'; import mockAudioEffects from '../../__mocks__/audio-effects.js'; -import SoundEditor from '../../../src/containers/sound-editor'; // eslint-disable-line no-unused-vars -// eslint-disable-next-line no-unused-vars +import SoundEditor from '../../../src/containers/sound-editor'; import SoundEditorComponent from '../../../src/components/sound-editor/sound-editor'; jest.mock('../../../src/lib/audio/audio-buffer-player', () => mockAudioBufferPlayer); @@ -17,7 +15,7 @@ describe('Sound Editor Container', () => { let store; let soundIndex; let soundBuffer; - let samples = new Float32Array([0, 0, 0]); // eslint-disable-line no-undef + const samples = new Float32Array([0, 0, 0]); // eslint-disable-line no-undef let vm; beforeEach(() => { @@ -40,7 +38,12 @@ describe('Sound Editor Container', () => { }); test('should pass the correct data to the component from the store', () => { - const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const wrapper = mountWithIntl( + <SoundEditor + soundIndex={soundIndex} + store={store} + /> + ); const componentProps = wrapper.find(SoundEditorComponent).props(); // Data retreived and processed by the `connect` with the store expect(componentProps.name).toEqual('first name'); @@ -54,7 +57,12 @@ describe('Sound Editor Container', () => { }); test('it plays when clicked and stops when clicked again', () => { - const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const wrapper = mountWithIntl( + <SoundEditor + soundIndex={soundIndex} + store={store} + /> + ); const component = wrapper.find(SoundEditorComponent); // Ensure rendering doesn't start playing any sounds expect(mockAudioBufferPlayer.instance.play.mock.calls).toEqual([]); @@ -73,7 +81,12 @@ describe('Sound Editor Container', () => { }); test('it sets the component props for trimming and submits to the vm', () => { - const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const wrapper = mountWithIntl( + <SoundEditor + soundIndex={soundIndex} + store={store} + /> + ); const component = wrapper.find(SoundEditorComponent); component.props().onActivateTrim(); @@ -87,14 +100,24 @@ describe('Sound Editor Container', () => { }); test('it submits name changes to the vm', () => { - const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const wrapper = mountWithIntl( + <SoundEditor + soundIndex={soundIndex} + store={store} + /> + ); const component = wrapper.find(SoundEditorComponent); component.props().onChangeName('hello'); expect(vm.renameSound).toHaveBeenCalledWith(soundIndex, 'hello'); }); - test('it handles an effect by submitting the result and playing', () => { - const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + test('it handles an effect by submitting the result and playing', done => { + const wrapper = mountWithIntl( + <SoundEditor + soundIndex={soundIndex} + store={store} + /> + ); const component = wrapper.find(SoundEditorComponent); component.props().onReverse(); // Could be any of the effects, just testing the end result mockAudioEffects.instance._finishProcessing(soundBuffer); @@ -103,7 +126,12 @@ describe('Sound Editor Container', () => { }); test('it handles reverse effect correctly', () => { - const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const wrapper = mountWithIntl( + <SoundEditor + soundIndex={soundIndex} + store={store} + /> + ); const component = wrapper.find(SoundEditorComponent); component.props().onReverse(); expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.REVERSE); @@ -111,7 +139,12 @@ describe('Sound Editor Container', () => { }); test('it handles louder effect correctly', () => { - const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const wrapper = mountWithIntl( + <SoundEditor + soundIndex={soundIndex} + store={store} + /> + ); const component = wrapper.find(SoundEditorComponent); component.props().onLouder(); expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.LOUDER); @@ -119,7 +152,12 @@ describe('Sound Editor Container', () => { }); test('it handles softer effect correctly', () => { - const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const wrapper = mountWithIntl( + <SoundEditor + soundIndex={soundIndex} + store={store} + /> + ); const component = wrapper.find(SoundEditorComponent); component.props().onSofter(); expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.SOFTER); @@ -127,7 +165,12 @@ describe('Sound Editor Container', () => { }); test('it handles faster effect correctly', () => { - const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const wrapper = mountWithIntl( + <SoundEditor + soundIndex={soundIndex} + store={store} + /> + ); const component = wrapper.find(SoundEditorComponent); component.props().onFaster(); expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.FASTER); @@ -135,7 +178,12 @@ describe('Sound Editor Container', () => { }); test('it handles slower effect correctly', () => { - const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const wrapper = mountWithIntl( + <SoundEditor + soundIndex={soundIndex} + store={store} + /> + ); const component = wrapper.find(SoundEditorComponent); component.props().onSlower(); expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.SLOWER); @@ -143,7 +191,12 @@ describe('Sound Editor Container', () => { }); test('it handles echo effect correctly', () => { - const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const wrapper = mountWithIntl( + <SoundEditor + soundIndex={soundIndex} + store={store} + /> + ); const component = wrapper.find(SoundEditorComponent); component.props().onEcho(); expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.ECHO); @@ -151,7 +204,12 @@ describe('Sound Editor Container', () => { }); test('it handles robot effect correctly', () => { - const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const wrapper = mountWithIntl( + <SoundEditor + soundIndex={soundIndex} + store={store} + /> + ); const component = wrapper.find(SoundEditorComponent); component.props().onRobot(); expect(mockAudioEffects.instance.name).toEqual(mockAudioEffects.effectTypes.ROBOT); @@ -159,7 +217,12 @@ describe('Sound Editor Container', () => { }); test('undo/redo functionality', () => { - const wrapper = mountWithIntl(<SoundEditor store={store} soundIndex={soundIndex} />); + const wrapper = mountWithIntl( + <SoundEditor + soundIndex={soundIndex} + store={store} + /> + ); const component = wrapper.find(SoundEditorComponent); // Undo and redo should be disabled initially expect(component.prop('canUndo')).toEqual(false); diff --git a/test/unit/containers/sprite-selector-item.test.jsx b/test/unit/containers/sprite-selector-item.test.jsx index 1efd40cb7cc05a26f9521dee06ad3935225fd7e8..2a371a1a0dfe763c32737ee832a9a3009e57b0e7 100644 --- a/test/unit/containers/sprite-selector-item.test.jsx +++ b/test/unit/containers/sprite-selector-item.test.jsx @@ -1,11 +1,10 @@ -/* eslint-env jest */ -import React from 'react'; // eslint-disable-line no-unused-vars -import {mountWithIntl} from '../../helpers/intl-helpers'; +import React from 'react'; +import {mountWithIntl} from '../../helpers/intl-helpers.jsx'; import configureStore from 'redux-mock-store'; -import {Provider} from 'react-redux'; // eslint-disable-line no-unused-vars +import {Provider} from 'react-redux'; -import SpriteSelectorItem from '../../../src/containers/sprite-selector-item'; // eslint-disable-line no-unused-vars -import CloseButton from '../../../src/components/close-button/close-button'; // eslint-disable-line no-unused-vars +import SpriteSelectorItem from '../../../src/containers/sprite-selector-item'; +import CloseButton from '../../../src/components/close-button/close-button'; describe('SpriteSelectorItem Container', () => { const mockStore = configureStore(); @@ -19,14 +18,19 @@ describe('SpriteSelectorItem Container', () => { let store; // Wrap this in a function so it gets test specific states and can be reused. const getContainer = function () { - return <Provider store={store}><SpriteSelectorItem - className={className} - costumeURL={costumeURL} - id={id} - name={name} - onClick={onClick} - onDeleteButtonClick={onDeleteButtonClick} - selected={selected}/></Provider>; + return ( + <Provider store={store}> + <SpriteSelectorItem + className={className} + costumeURL={costumeURL} + id={id} + name={name} + selected={selected} + onClick={onClick} + onDeleteButtonClick={onDeleteButtonClick} + /> + </Provider> + ); }; beforeEach(() => { diff --git a/test/unit/util/audio-effects.test.js b/test/unit/util/audio-effects.test.js index e30a2e6c14d40528fddf8b5dd2f69ebc93bec5f0..d1057de53777355a9d9f9a7dfbf4babf95c3e63d 100644 --- a/test/unit/util/audio-effects.test.js +++ b/test/unit/util/audio-effects.test.js @@ -1,5 +1,4 @@ -/* eslint-env jest */ -/* global AudioNode AudioContext WebAudioTestAPI */ +/* global WebAudioTestAPI */ import 'web-audio-test-api'; WebAudioTestAPI.setState({ 'OfflineAudioContext#startRendering': 'promise' @@ -11,8 +10,8 @@ import EchoEffect from '../../../src/lib/audio/effects/echo-effect'; import VolumeEffect from '../../../src/lib/audio/effects/volume-effect'; describe('Audio Effects manager', () => { - let audioContext = new AudioContext(); - let audioBuffer = audioContext.createBuffer(1, 400, 44100); + const audioContext = new AudioContext(); + const audioBuffer = audioContext.createBuffer(1, 400, 44100); test('changes buffer length and playback rate for faster effect', () => { const audioEffects = new AudioEffects(audioBuffer, 'faster'); diff --git a/test/unit/util/audio-util.test.js b/test/unit/util/audio-util.test.js index 743a5b1c492894ce420b1f6bb66fa977eb2f4522..24ddcd4559567dce0fd1677a11a809c365233699 100644 --- a/test/unit/util/audio-util.test.js +++ b/test/unit/util/audio-util.test.js @@ -1,4 +1,3 @@ -/* eslint-env jest */ import {computeRMS, computeChunkedRMS} from '../../../src/lib/audio/audio-util'; describe('computeRMS', () => { diff --git a/test/unit/util/project-loader-hoc.test.jsx b/test/unit/util/project-loader-hoc.test.jsx new file mode 100644 index 0000000000000000000000000000000000000000..964894c4369441e328e6e365f97631d3736f15b6 --- /dev/null +++ b/test/unit/util/project-loader-hoc.test.jsx @@ -0,0 +1,51 @@ +import React from 'react'; +import ProjectLoaderHOC, {ProjectLoader} from '../../../src/lib/project-loader-hoc.jsx'; +import {mount} from 'enzyme'; + +describe('ProjectLoaderHOC', () => { + test('when there is no project data, it renders null', () => { + const Component = ({projectData}) => <div>{projectData}</div>; + const WrappedComponent = ProjectLoaderHOC(Component); + window.location.hash = '#winning'; + ProjectLoader.load = jest.fn((id, cb) => cb(null, null)); + const mounted = mount(<WrappedComponent />); + ProjectLoader.load.mockRestore(); + window.location.hash = ''; + expect(mounted.find('div').exists()).toEqual(false); + }); + + test('when there is no hash, it loads the default project', () => { + const Component = ({projectData}) => <div>{projectData}</div>; + const WrappedComponent = ProjectLoaderHOC(Component); + window.location.hash = ''; + const mounted = mount(<WrappedComponent />); + expect(mounted.find('div').text()).toEqual(JSON.stringify(ProjectLoader.DEFAULT_PROJECT_DATA)); + }); + + test('when there is a hash, it tries to load that project', () => { + const Component = ({projectData}) => <div>{projectData}</div>; + const WrappedComponent = ProjectLoaderHOC(Component); + window.location.hash = '#winning'; + ProjectLoader.load = jest.fn((id, cb) => cb(null, id)); + const mounted = mount(<WrappedComponent />); + mounted.update(); + ProjectLoader.load.mockRestore(); + window.location.hash = ''; + expect(mounted + .find('div') + .text() + ).toEqual('winning'); + }); + + test('when hash change happens, the project data state is changed', () => { + const Component = ({projectData}) => <div>{projectData}</div>; + const WrappedComponent = ProjectLoaderHOC(Component); + window.location.hash = ''; + const mounted = mount(<WrappedComponent />); + const before = mounted.find('div').text(); + ProjectLoader.load = jest.fn((id, cb) => cb(null, id)); + window.location.hash = `#winning`; + mounted.node.updateProject(); + expect(mounted.find('div').text()).not.toEqual(before); + }); +}); diff --git a/webpack.config.js b/webpack.config.js index 44595ff2cd505fc28a3e6bc4666732a802c0c86d..f39fc81a9f7cf0f487d01fd6077e8d5a548de77c 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -19,7 +19,9 @@ module.exports = { devtool: 'cheap-module-source-map', entry: { lib: ['react', 'react-dom'], - gui: './src/index.jsx' + gui: './src/index.jsx', + blocksonly: './src/examples/blocks-only.jsx', + player: './src/examples/player.jsx' }, output: { path: path.resolve(__dirname, 'build'), @@ -78,9 +80,22 @@ module.exports = { filename: 'lib.min.js' }), new HtmlWebpackPlugin({ + chunks: ['lib', 'gui'], template: 'src/index.ejs', title: 'Scratch 3.0 GUI' }), + new HtmlWebpackPlugin({ + chunks: ['lib', 'blocksonly'], + template: 'src/index.ejs', + filename: 'blocks-only.html', + title: 'Scratch 3.0 GUI: Blocks Only Example' + }), + new HtmlWebpackPlugin({ + chunks: ['lib', 'player'], + template: 'src/index.ejs', + filename: 'player.html', + title: 'Scratch 3.0 GUI: Player Example' + }), new CopyWebpackPlugin([{ from: 'node_modules/scratch-blocks/media', to: 'static/blocks-media'