diff --git a/package.json b/package.json index 7840f355b..a58e55026 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "eslint-config-scratch": "^5.0.0", "eslint-plugin-import": "^2.8.0", "eslint-plugin-react": "^7.5.1", - "file-loader": "1.1.6", + "file-loader": "1.1.9", "get-float-time-domain-data": "0.1.0", "get-user-media-promise": "1.1.1", "gh-pages": "github:rschamp/gh-pages#publish-branch-to-subfolder", @@ -77,8 +77,8 @@ "react-ga": "2.4.1", "react-intl": "2.4.0", "react-intl-redux": "0.7.0", - "react-modal": "3.1.13", - "react-redux": "5.0.6", + "react-modal": "3.3.1", + "react-redux": "5.0.7", "react-responsive": "4.0.3", "react-style-proptype": "3.2.0", "react-tabs": "2.2.1", @@ -89,12 +89,12 @@ "redux-throttle": "0.1.1", "rimraf": "^2.6.1", "scratch-audio": "0.1.0-prerelease.1516198804", - "scratch-blocks": "0.1.0-prerelease.1518210548", + "scratch-blocks": "0.1.0-prerelease.1519256473", "scratch-l10n": "2.0.20180108132626", - "scratch-paint": "0.1.0-prerelease.20180209163938", + "scratch-paint": "0.2.0-prerelease.20180227161624", "scratch-render": "0.1.0-prerelease.1516837442", "scratch-storage": "0.4.0", - "scratch-vm": "0.1.0-prerelease.1518098842-prerelease.1518098861", + "scratch-vm": "0.1.0-prerelease.1519681201-prerelease.1519681262", "selenium-webdriver": "3.6.0", "startaudiocontext": "1.2.1", "style-loader": "^0.20.0", diff --git a/src/components/action-menu/action-menu.css b/src/components/action-menu/action-menu.css new file mode 100644 index 000000000..c222216b1 --- /dev/null +++ b/src/components/action-menu/action-menu.css @@ -0,0 +1,174 @@ +@import "../../css/colors.css"; + +$main-button-size: 2.75rem; +$more-button-size: 2.25rem; + +.menu-container { + display: flex; + flex-direction: column-reverse; + transition: 0.2s; + position: relative; +} + +.button { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + cursor: pointer; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; + background: $motion-primary; + outline: none; + border: none; + transition: background-color 0.2s; +} + +.button:hover { + background: $pen-primary; +} + +.button.coming-soon:hover { + background: $data-primary; +} + +.main-button { + border-radius: 100%; + width: $main-button-size; + height: $main-button-size; + z-index: 20; /* TODO reorder layout to prevent z-index need */ +} + +.main-icon { + width: calc($main-button-size - 1rem); + height: calc($main-button-size - 1rem); +} + +.more-buttons-outer { + /* + Need to use two divs to set different overflow x/y + which is needed to get animation to look right while + allowing the tooltips to be visible. + */ + overflow-y: hidden; + + background: $motion-tertiary; + border-top-left-radius: $more-button-size; + border-top-right-radius: $more-button-size; + width: $more-button-size; + margin-left: calc(($main-button-size - $more-button-size) / 2); + + position: absolute; + bottom: calc($main-button-size); + + margin-bottom: calc($main-button-size / -2); + padding-bottom: calc($main-button-size / 2); +} + +.more-buttons { + max-height: 0; + transition: max-height 1s; + overflow-x: visible; + display: flex; + flex-direction: column; + z-index: 10; /* @todo justify */ +} + +.expanded .more-buttons { + max-height: 1000px; /* Arbitrary, needs to be a value in order for animation to run */ +} + +.force-hidden .more-buttons { + display: none; /* This property does not animate */ +} + +.more-buttons:first-child { /* Round off top button */ + border-top-right-radius: $more-button-size; + border-top-left-radius: $more-button-size; +} + +.more-button { + width: $more-button-size; + height: $more-button-size; + background: $motion-tertiary; +} + +.more-icon { + width: calc($more-button-size - 1rem); + height: calc($more-button-size - 1rem); +} + +.coming-soon .more-icon { + opacity: 0.5; +} + +/* + @todo needs to be refactored with coming soon tooltip overrides. + The "!important"s are for the same reason as with coming soon, the library + is not very easy to style. +*/ +.tooltip { + background-color: $pen-primary !important; + opacity: 1 !important; + border: 1px solid hsla(0, 0%, 0%, .1) !important; + box-shadow: 0 0 .5rem hsla(0, 0%, 0%, .25) !important; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !important; +} + +.tooltip:after { + background-color: $pen-primary; +} + +.coming-soon-tooltip { + background-color: $data-primary !important; +} + +.coming-soon-tooltip:after { + background-color: $data-primary !important; +} + +.tooltip { + border: 1px solid hsla(0, 0%, 0%, .1) !important; + border-radius: .25rem !important; + box-shadow: 0 0 .5rem hsla(0, 0%, 0%, .25) !important; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif !important; + z-index: 100 !important; +} + +$arrow-size: 0.5rem; +$arrow-inset: -0.25rem; +$arrow-rounding: 0.125rem; + +.tooltip:after { + content: ""; + border-top: 1px solid hsla(0, 0%, 0%, .1) !important; + border-left: 0 !important; + border-bottom: 0 !important; + border-right: 1px solid hsla(0, 0%, 0%, .1) !important; + border-radius: $arrow-rounding; + height: $arrow-size !important; + width: $arrow-size !important; +} + +.tooltip:global(.place-left):after { + margin-top: $arrow-inset !important; + right: $arrow-inset !important; + transform: rotate(45deg) !important; +} + +.tooltip:global(.place-right):after { + margin-top: $arrow-inset !important; + left: $arrow-inset !important; + transform: rotate(-135deg) !important; +} + +.tooltip:global(.place-top):after { + margin-right: $arrow-inset !important; + bottom: $arrow-inset !important; + transform: rotate(135deg) !important; +} + +.tooltip:global(.place-bottom):after { + margin-left: $arrow-inset !important; + top: $arrow-inset !important; + transform: rotate(-45deg) !important; +} diff --git a/src/components/action-menu/action-menu.jsx b/src/components/action-menu/action-menu.jsx new file mode 100644 index 000000000..910d1ab6a --- /dev/null +++ b/src/components/action-menu/action-menu.jsx @@ -0,0 +1,189 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import classNames from 'classnames'; +import bindAll from 'lodash.bindall'; +import ReactTooltip from 'react-tooltip'; + +import styles from './action-menu.css'; + +const CLOSE_DELAY = 300; // ms + +class ActionMenu extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'clickDelayer', + 'handleClosePopover', + 'handleToggleOpenState', + 'handleTouchStart', + 'handleTouchOutside', + 'setButtonRef', + 'setContainerRef' + ]); + this.state = { + isOpen: false, + forceHide: false + }; + } + componentDidMount () { + // Touch start on the main button is caught to trigger open and not click + this.buttonRef.addEventListener('touchstart', this.handleTouchStart); + // Touch start on document is used to trigger close if it is outside + document.addEventListener('touchstart', this.handleTouchOutside); + } + shouldComponentUpdate (newProps, newState) { + // This check prevents re-rendering while the project is updating. + // @todo check only the state and the title because it is enough to know + // if anything substantial has changed + // This is needed because of the sloppy way the props are passed as a new object, + // which should be refactored. + return newState.isOpen !== this.state.isOpen || + newState.forceHide !== this.state.forceHide || + newProps.title !== this.props.title; + } + componentWillUnmount () { + this.buttonRef.removeEventListener('touchstart', this.handleTouchStart); + document.removeEventListener('touchstart', this.handleTouchOutside); + } + handleClosePopover () { + this.closeTimeoutId = setTimeout(() => { + this.setState({isOpen: false}); + this.closeTimeoutId = null; + }, CLOSE_DELAY); + } + handleToggleOpenState () { + // Mouse enter back in after timeout was started prevents it from closing. + if (this.closeTimeoutId) { + clearTimeout(this.closeTimeoutId); + this.closeTimeoutId = null; + } else if (!this.state.isOpen) { + this.setState({ + isOpen: true, + forceHide: false + }); + } + } + handleTouchOutside (e) { + if (this.state.isOpen && !this.containerRef.contains(e.target)) { + this.setState({isOpen: false}); + } + } + clickDelayer (fn) { + // Return a wrapped action that manages the menu closing. + // @todo we may be able to use react-transition for this in the future + // for now all this work is to ensure the menu closes BEFORE the + // (possibly slow) action is started. + return event => { + this.setState({forceHide: true, isOpen: false}, () => { + if (fn) fn(event); + setTimeout(() => this.setState({forceHide: false})); + }); + }; + } + handleTouchStart (e) { + // Prevent this touch from becoming a click if menu is closed + if (!this.state.isOpen) { + e.preventDefault(); + this.handleToggleOpenState(); + } + } + setButtonRef (ref) { + this.buttonRef = ref; + } + setContainerRef (ref) { + this.containerRef = ref; + } + render () { + const { + className, + img: mainImg, + title: mainTitle, + moreButtons, + onClick + } = this.props; + + const mainTooltipId = `tooltip-${Math.random()}`; + + return ( +
+ + +
+
+ {(moreButtons || []).map(({img, title, onClick: handleClick}) => { + const isComingSoon = !handleClick; + const tooltipId = `tooltip-${Math.random()}`; + return ( +
+ + +
+ ); + })} +
+
+
+ ); + } +} + +ActionMenu.propTypes = { + className: PropTypes.string, + img: PropTypes.string, + moreButtons: PropTypes.arrayOf(PropTypes.shape({ + img: PropTypes.string, + title: PropTypes.node.isRequired, + onClick: PropTypes.func // Optional, "coming soon" if no callback provided + })), + onClick: PropTypes.func.isRequired, + title: PropTypes.node.isRequired +}; + +export default ActionMenu; diff --git a/src/components/stage-selector/icon--backdrop.svg b/src/components/action-menu/icon--backdrop.svg similarity index 100% rename from src/components/stage-selector/icon--backdrop.svg rename to src/components/action-menu/icon--backdrop.svg diff --git a/src/components/action-menu/icon--camera.svg b/src/components/action-menu/icon--camera.svg new file mode 100644 index 000000000..e8c442d8f --- /dev/null +++ b/src/components/action-menu/icon--camera.svg @@ -0,0 +1,12 @@ + + + + camera + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/src/components/action-menu/icon--file-upload.svg b/src/components/action-menu/icon--file-upload.svg new file mode 100644 index 000000000..57337d959 --- /dev/null +++ b/src/components/action-menu/icon--file-upload.svg @@ -0,0 +1,12 @@ + + + + file-upload + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/src/components/action-menu/icon--paint.svg b/src/components/action-menu/icon--paint.svg new file mode 100644 index 000000000..f79d562c3 --- /dev/null +++ b/src/components/action-menu/icon--paint.svg @@ -0,0 +1,12 @@ + + + + paint + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/src/components/sprite-selector/icon--sprite.svg b/src/components/action-menu/icon--sprite.svg similarity index 100% rename from src/components/sprite-selector/icon--sprite.svg rename to src/components/action-menu/icon--sprite.svg diff --git a/src/components/action-menu/icon--surprise.svg b/src/components/action-menu/icon--surprise.svg new file mode 100644 index 000000000..41655999f --- /dev/null +++ b/src/components/action-menu/icon--surprise.svg @@ -0,0 +1,12 @@ + + + + surprise + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/src/components/asset-button/asset-button.css b/src/components/asset-button/asset-button.css deleted file mode 100644 index b36fa76b8..000000000 --- a/src/components/asset-button/asset-button.css +++ /dev/null @@ -1,21 +0,0 @@ -@import "../../css/colors.css"; - -.container { - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - cursor: pointer; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - background: $motion-primary; - border-radius: 100%; - width: 2.75rem; - height: 2.75rem; - outline: none; - border: none; -} - -.icon { - width: 1.75rem; - height: 1.75rem; -} diff --git a/src/components/asset-button/asset-button.jsx b/src/components/asset-button/asset-button.jsx deleted file mode 100644 index 088838990..000000000 --- a/src/components/asset-button/asset-button.jsx +++ /dev/null @@ -1,32 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import classNames from 'classnames'; -import styles from './asset-button.css'; - -const AssetButton = ({ - img, - className, - title, - onClick -}) => ( - -); - -AssetButton.propTypes = { - className: PropTypes.string, - img: PropTypes.string, - onClick: PropTypes.func.isRequired, - title: PropTypes.node.isRequired -}; - -export default AssetButton; diff --git a/src/components/asset-panel/selector.css b/src/components/asset-panel/selector.css index 2a19296d6..db9bca069 100644 --- a/src/components/asset-panel/selector.css +++ b/src/components/asset-panel/selector.css @@ -11,13 +11,31 @@ } .new-buttons { + position: absolute; + bottom: 0; + width: 100%; + display: flex; flex-direction: column; align-items: center; justify-content: space-around; - margin: 0.75rem 0; + padding: 0.75rem 0; color: $motion-primary; text-align: center; + background: none; +} + +$fade-out-distance: 100px; + +.new-buttons:before { + content: ""; + position: absolute; + bottom: 0; + left: 0; + background: linear-gradient(rgba(232,237,241, 0),rgba(232,237,241, 1)); + height: $fade-out-distance; + width: 100%; + pointer-events: none; } .new-buttons > button + button { diff --git a/src/components/asset-panel/selector.jsx b/src/components/asset-panel/selector.jsx index 012206517..f2ab58cae 100644 --- a/src/components/asset-panel/selector.jsx +++ b/src/components/asset-panel/selector.jsx @@ -4,7 +4,7 @@ import React from 'react'; import SpriteSelectorItem from '../../containers/sprite-selector-item.jsx'; import Box from '../box/box.jsx'; -import AssetButton from '../asset-button/asset-button.jsx'; +import ActionMenu from '../action-menu/action-menu.jsx'; import styles from './selector.css'; const Selector = props => { @@ -13,9 +13,27 @@ const Selector = props => { items, selectedItemIndex, onDeleteClick, + onDuplicateClick, onItemClick } = props; + let newButtonSection = null; + + if (buttons.length > 0) { + const {img, title, onClick} = buttons[0]; + const moreButtons = buttons.slice(1); + newButtonSection = ( + + + + ); + } + return ( @@ -30,19 +48,11 @@ const Selector = props => { selected={index === selectedItemIndex} onClick={onItemClick} onDeleteButtonClick={onDeleteClick} + onDuplicateButtonClick={onDuplicateClick} /> ))} - - {buttons.map(({message, img, onClick}, index) => ( - - ))} - + {newButtonSection} ); }; @@ -58,6 +68,7 @@ Selector.propTypes = { name: PropTypes.string.isRequired })), onDeleteClick: PropTypes.func, + onDuplicateClick: PropTypes.func, onItemClick: PropTypes.func.isRequired, selectedItemIndex: PropTypes.number.isRequired }; diff --git a/src/components/close-button/close-button.css b/src/components/close-button/close-button.css index 1864e7b2a..88ebb526e 100644 --- a/src/components/close-button/close-button.css +++ b/src/components/close-button/close-button.css @@ -49,3 +49,18 @@ width: 0.75rem; height: 0.75rem; } + +.back-icon { + position: relative; + margin: 0.25rem; + user-select: none; +} + +.small .back-icon { + width: 50%; +} + +.large .back-icon { + width: 2rem; + height: 2rem; +} diff --git a/src/components/close-button/close-button.jsx b/src/components/close-button/close-button.jsx index 039a8750b..befac229f 100644 --- a/src/components/close-button/close-button.jsx +++ b/src/components/close-button/close-button.jsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import styles from './close-button.css'; import closeIcon from './icon--close.svg'; +import backIcon from './icon--back.svg'; const CloseButton = props => (
( tabIndex="0" onClick={props.onClick} > - + {props.buttonType === 'back' ? + : + + }
); @@ -31,13 +38,15 @@ CloseButton.SIZE_SMALL = 'small'; CloseButton.SIZE_LARGE = 'large'; CloseButton.propTypes = { + buttonType: PropTypes.oneOf(['back', 'close']), className: PropTypes.string, onClick: PropTypes.func.isRequired, size: PropTypes.oneOf([CloseButton.SIZE_SMALL, CloseButton.SIZE_LARGE]) }; CloseButton.defaultProps = { - size: CloseButton.SIZE_LARGE + size: CloseButton.SIZE_LARGE, + buttonType: 'close' }; export default CloseButton; diff --git a/src/components/close-button/icon--back.svg b/src/components/close-button/icon--back.svg new file mode 100644 index 000000000..e3f82a6fd --- /dev/null +++ b/src/components/close-button/icon--back.svg @@ -0,0 +1,12 @@ + + + + back-icon + Created with Sketch. + + + + + + + \ No newline at end of file diff --git a/src/components/green-flag/green-flag.css b/src/components/green-flag/green-flag.css index 2c1452838..35ba29726 100644 --- a/src/components/green-flag/green-flag.css +++ b/src/components/green-flag/green-flag.css @@ -5,6 +5,7 @@ padding: 0.375rem; border-radius: 0.25rem; user-select: none; + user-drag: none; cursor: pointer; transition: 0.2s ease-out; } diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index 92b83acc2..5132af685 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -140,7 +140,8 @@ For making the sprite-selector a scrollable pane @todo: Not working in Safari */ - overflow: hidden; + /* TODO this also breaks the thermometer menu */ + /* overflow: hidden; */ } .extension-button-container { @@ -156,6 +157,19 @@ box-sizing: content-box; /* To match scratch-block vertical toolbox borders */ } +$fade-out-distance: 15px; + +.extension-button-container:before { + content: ""; + position: absolute; + top: calc(calc(-1 * $fade-out-distance) - 1px); + left: -1px; + background: linear-gradient(rgba(0, 0, 0, 0),rgba(0, 0, 0, 0.15)); + height: $fade-out-distance; + width: calc(100% + 0.5px); +} + + .extension-button { background: none; border: none; diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index fe58243ba..5c7a378dc 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -19,6 +19,7 @@ import Box from '../box/box.jsx'; import FeedbackForm from '../feedback-form/feedback-form.jsx'; import MenuBar from '../menu-bar/menu-bar.jsx'; import PreviewModal from '../../containers/preview-modal.jsx'; +import ImportModal from '../../containers/import-modal.jsx'; import WebGlModal from '../../containers/webgl-modal.jsx'; import layout from '../../lib/layout-constants.js'; @@ -35,16 +36,19 @@ const messages = defineMessages({ const GUIComponent = props => { const { + activeTabIndex, basePath, + blocksTabVisible, children, - enableExtensions, - intl, + costumesTabVisible, feedbackFormVisible, - vm, - previewInfoVisible, + importInfoVisible, + intl, onExtensionButtonClick, - onTabSelect, - tabIndex, + onActivateTab, + previewInfoVisible, + soundsTabVisible, + vm, ...componentProps } = props; if (children) { @@ -74,6 +78,9 @@ const GUIComponent = props => { {previewInfoVisible ? ( ) : null} + {importInfoVisible ? ( + + ) : null} {feedbackFormVisible ? ( ) : null} @@ -87,9 +94,10 @@ const GUIComponent = props => { Blocks @@ -100,7 +108,7 @@ const GUIComponent = props => { { + + {props.hasValidationError ? + +

+ +

+
: null + } + + + + + + + + ) + }} + /> + + + +); + +ImportModal.propTypes = { + errorMessage: PropTypes.string.isRequired, + hasValidationError: PropTypes.bool.isRequired, + inputValue: PropTypes.string.isRequired, + intl: intlShape.isRequired, + onCancel: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onGoBack: PropTypes.func.isRequired, + onKeyPress: PropTypes.func.isRequired, + onViewProject: PropTypes.func.isRequired, + placeholder: PropTypes.string +}; + +export default injectIntl(ImportModal); diff --git a/src/components/monitor/monitor.jsx b/src/components/monitor/monitor.jsx index 17b562120..5fb521e9c 100644 --- a/src/components/monitor/monitor.jsx +++ b/src/components/monitor/monitor.jsx @@ -9,7 +9,8 @@ const categories = { sensing: '#5CB1D6', sound: '#CF63CF', looks: '#9966FF', - motion: '#4C97FF' + motion: '#4C97FF', + list: '#FC662C' }; const MonitorComponent = props => ( diff --git a/src/components/preview-modal/preview-modal.css b/src/components/preview-modal/preview-modal.css index 858beb5ba..c6e5037e8 100644 --- a/src/components/preview-modal/preview-modal.css +++ b/src/components/preview-modal/preview-modal.css @@ -51,7 +51,7 @@ .button-row button { border: 1px solid $motion-primary; border-radius: 0.25rem; - padding: 0.5rem 2rem; + padding: 0.5rem 1.5rem; background: white; font-weight: bold; font-size: .875rem; @@ -66,6 +66,13 @@ .button-row button.no-button { color: $motion-primary; } + +.button-row button.view-project-button { + background: $import-primary; + border-color: $import-primary; + color: white; +} + .button-row button + button { margin-left: 0.5rem; } diff --git a/src/components/preview-modal/preview-modal.jsx b/src/components/preview-modal/preview-modal.jsx index 71a3b029c..5772b5185 100644 --- a/src/components/preview-modal/preview-modal.jsx +++ b/src/components/preview-modal/preview-modal.jsx @@ -71,6 +71,17 @@ const PreviewModal = ({intl, ...props}) => ( }} /> + ( PreviewModal.propTypes = { intl: intlShape.isRequired, onCancel: PropTypes.func.isRequired, - onTryIt: PropTypes.func.isRequired + onTryIt: PropTypes.func.isRequired, + onViewProject: PropTypes.func.isRequired }; export default injectIntl(PreviewModal); diff --git a/src/components/sprite-selector-item/sprite-selector-item.jsx b/src/components/sprite-selector-item/sprite-selector-item.jsx index aa292cfaa..abb47bb54 100644 --- a/src/components/sprite-selector-item/sprite-selector-item.jsx +++ b/src/components/sprite-selector-item/sprite-selector-item.jsx @@ -18,7 +18,9 @@ const SpriteSelectorItem = props => ( className: classNames(props.className, styles.spriteSelectorItem, { [styles.isSelected]: props.selected }), - onClick: props.onClick + onClick: props.onClick, + onMouseEnter: props.onMouseEnter, + onMouseLeave: props.onMouseLeave }} id={`${props.name}-${contextMenuId}`} > @@ -70,6 +72,8 @@ SpriteSelectorItem.propTypes = { onClick: PropTypes.func, onDeleteButtonClick: PropTypes.func, onDuplicateButtonClick: PropTypes.func, + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, selected: PropTypes.bool.isRequired }; diff --git a/src/components/sprite-selector/sprite-selector.css b/src/components/sprite-selector/sprite-selector.css index 67b0532d3..ec5cfee2b 100644 --- a/src/components/sprite-selector/sprite-selector.css +++ b/src/components/sprite-selector/sprite-selector.css @@ -2,7 +2,7 @@ .sprite-selector { flex-grow: 1; - position: relative; + position: relative; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; margin-right: calc($space / 2); background-color: #f9f9f9; @@ -16,14 +16,14 @@ /* In prep for renaming sprite-selector-item to sprite */ .sprite { - /* + /* Our goal is to fit sprites evenly in a row without leftover space. - Flexbox's `space between` property gets us close, but doesn't flow + Flexbox's `space between` property gets us close, but doesn't flow well when the # of items per row > 1 and less than the max per row. - Solving by explicitly calc'ing the width of each sprite. Setting - `border-box` simplifies things, because content, padding and - border-width all are included in the width, leaving us only to subtract + Solving by explicitly calc'ing the width of each sprite. Setting + `border-box` simplifies things, because content, padding and + border-width all are included in the width, leaving us only to subtract the left + right margins. @todo: make room for the scrollbar @@ -32,7 +32,7 @@ width: calc((100% / $sprites-per-row ) - $space); min-width: 4rem; min-height: 4rem; /* @todo: calc height same as width */ - margin: calc($space / 2); + margin: calc($space / 2); } @@ -41,11 +41,11 @@ Sets the sprite-selector items as a scrollable pane @todo: Safari: pane doesn't stretch to fill height; - @todo: Adding `position: relative` still doesn't fix Safari scrolling pane, and - also introduces a new bug in Chrome when vertically resizing window down, - then back up, introduces white space in the outside the page container. + @todo: Adding `position: relative` still doesn't fix Safari scrolling pane, and + also introduces a new bug in Chrome when vertically resizing window down, + then back up, introduces white space in the outside the page container. */ - height: calc(100% - $sprite-info-height); + height: calc(100% - $sprite-info-height); overflow-y: scroll; } @@ -57,11 +57,90 @@ padding-top: calc($space / 2); padding-left: calc($space / 2); padding-right: calc($space / 2); - padding-bottom: $space; + padding-bottom: $space; } .add-button { position: absolute; bottom: 0.75rem; right: 1rem; + z-index: 1; /* TODO overlaps the stage, this doesn't work, fix! */ +} + +.raised { + background-color: #cce1ff; + border-color: #8cbcff; + transition: all 0.25s ease; +} + +.raised:hover { + background-color: #8cbcff; + transform: scale(1.05); +} + +.raised:hover { + -webkit-animation-name: wiggle; + -ms-animation-name: wiggle; + -ms-animation-duration: 500ms; + -webkit-animation-duration: 500ms; + -webkit-animation-iteration-count: 1; + -ms-animation-iteration-count: 1; + -webkit-animation-timing-function: ease-in-out; + -ms-animation-timing-function: ease-in-out; + background-color: #8cbcff; +} + +@-webkit-keyframes wiggle { + 0% {-webkit-transform: rotate(3deg) scale(1.05);} + 25% {-webkit-transform: rotate(-3deg) scale(1.05);} + 50% {-webkit-transform: rotate(5deg) scale(1.05);} + 75% {-webkit-transform: rotate(-2deg) scale(1.05);} + 100% {-webkit-transform: rotate(0deg) scale(1.05);} +} + +@-ms-keyframes wiggle { + 0% {-ms-transform: rotate(3deg) scale(1.05);} + 25% {-ms-transform: rotate(-3deg) scale(1.05);} + 50% {-ms-transform: rotate(5deg) scale(1.05);} + 75% {-ms-transform: rotate(-2deg) scale(1.05);} + 100% {-ms-transform: rotate(0deg) scale(1.05);} +} + +@keyframes wiggle { + 0% {transform: rotate(3deg) scale(1.05);} + 25% {transform: rotate(-3deg) scale(1.05);} + 50% {transform: rotate(5deg) scale(1.05);} + 75% {transform: rotate(-2deg) scale(1.05);} + 100% {transform: rotate(0deg) scale(1.05);} +} + +.receivedBlocks { + -webkit-animation: glowing 250ms; + -moz-animation: glowing 250ms; + -o-animation: glowing 250ms; + animation: glowing 250ms; +} + +@-webkit-keyframes glowing { + 10% { -webkit-box-shadow: 0 0 10px #7fff1e; } + 90% { -webkit-box-shadow: 0 0 10px #7fff1e; } + 100% { -webkit-box-shadow: none; } +} + +@-moz-keyframes glowing { + 10% { -moz-box-shadow: 0 0 10px #7fff1e; } + 90% { -moz-box-shadow: 0 0 10px #7fff1e; } + 100% { -moz-box-shadow: none; } +} + +@-o-keyframes glowing { + 10% { box-shadow: 0 0 10px #7fff1e; } + 90% { box-shadow: 0 0 10px #7fff1e; } + 100% { box-shadow: none; } +} + +@keyframes glowing { + 10% { box-shadow: 0 0 10px #7fff1e; } + 90% { box-shadow: 0 0 10px #7fff1e; } + 100% { box-shadow: none; } } diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx index 41e51a316..24e5315c6 100644 --- a/src/components/sprite-selector/sprite-selector.jsx +++ b/src/components/sprite-selector/sprite-selector.jsx @@ -1,25 +1,53 @@ import PropTypes from 'prop-types'; import React from 'react'; +import classNames from 'classnames'; import {defineMessages, injectIntl, intlShape} from 'react-intl'; import Box from '../box/box.jsx'; import SpriteInfo from '../../containers/sprite-info.jsx'; import SpriteSelectorItem from '../../containers/sprite-selector-item.jsx'; -import AssetButton from '../asset-button/asset-button.jsx'; +import ActionMenu from '../action-menu/action-menu.jsx'; import styles from './sprite-selector.css'; -import spriteIcon from './icon--sprite.svg'; + +import cameraIcon from '../action-menu/icon--camera.svg'; +import fileUploadIcon from '../action-menu/icon--file-upload.svg'; +import paintIcon from '../action-menu/icon--paint.svg'; +import spriteIcon from '../action-menu/icon--sprite.svg'; +import surpriseIcon from '../action-menu/icon--surprise.svg'; const messages = defineMessages({ - addSprite: { - id: 'gui.spriteSelector.addSprite', - description: 'Button to add a sprite in the target pane', - defaultMessage: 'Add Sprite' + addSpriteFromLibrary: { + id: 'gui.spriteSelector.addSpriteFromLibrary', + description: 'Button to add a sprite in the target pane from library', + defaultMessage: 'Sprite Library' + }, + addSpriteFromPaint: { + id: 'gui.spriteSelector.addSpriteFromPaint', + description: 'Button to add a sprite in the target pane from paint', + defaultMessage: 'Paint' + }, + addSpriteFromSurprise: { + id: 'gui.spriteSelector.addSpriteFromSurprise', + description: 'Button to add a random sprite in the target pane', + defaultMessage: 'Surprise' + }, + addSpriteFromFile: { + id: 'gui.spriteSelector.addSpriteFromFile', + description: 'Button to add a sprite in the target pane from file', + defaultMessage: 'Coming Soon' + }, + addSpriteFromCamera: { + id: 'gui.spriteSelector.addSpriteFromCamera', + description: 'Button to add a sprite in the target pane from camera', + defaultMessage: 'Coming Soon' } }); const SpriteSelectorComponent = function (props) { const { + editingTarget, + hoveredTarget, intl, onChangeSpriteDirection, onChangeSpriteName, @@ -30,7 +58,10 @@ const SpriteSelectorComponent = function (props) { onDeleteSprite, onDuplicateSprite, onNewSpriteClick, + onSurpriseSpriteClick, + onPaintSpriteClick, onSelectSprite, + raised, selectedId, sprites, ...componentProps @@ -72,7 +103,12 @@ const SpriteSelectorComponent = function (props) { .map(sprite => ( - @@ -96,6 +149,11 @@ const SpriteSelectorComponent = function (props) { }; SpriteSelectorComponent.propTypes = { + editingTarget: PropTypes.string, + hoveredTarget: PropTypes.shape({ + hoveredSprite: PropTypes.string, + receivedBlocks: PropTypes.bool + }), intl: intlShape.isRequired, onChangeSpriteDirection: PropTypes.func, onChangeSpriteName: PropTypes.func, @@ -106,7 +164,10 @@ SpriteSelectorComponent.propTypes = { onDeleteSprite: PropTypes.func, onDuplicateSprite: PropTypes.func, onNewSpriteClick: PropTypes.func, + onPaintSpriteClick: PropTypes.func, onSelectSprite: PropTypes.func, + onSurpriseSpriteClick: PropTypes.func, + raised: PropTypes.bool, selectedId: PropTypes.string, sprites: PropTypes.shape({ id: PropTypes.shape({ diff --git a/src/components/stage-selector/stage-selector.jsx b/src/components/stage-selector/stage-selector.jsx index eea811409..073ca257c 100644 --- a/src/components/stage-selector/stage-selector.jsx +++ b/src/components/stage-selector/stage-selector.jsx @@ -4,16 +4,41 @@ import React from 'react'; import {defineMessages, intlShape, injectIntl, FormattedMessage} from 'react-intl'; import Box from '../box/box.jsx'; -import AssetButton from '../asset-button/asset-button.jsx'; +import ActionMenu from '../action-menu/action-menu.jsx'; import CostumeCanvas from '../costume-canvas/costume-canvas.jsx'; import styles from './stage-selector.css'; -import backdropIcon from './icon--backdrop.svg'; + +import backdropIcon from '../action-menu/icon--backdrop.svg'; +import cameraIcon from '../action-menu/icon--camera.svg'; +import fileUploadIcon from '../action-menu/icon--file-upload.svg'; +import paintIcon from '../action-menu/icon--paint.svg'; +import surpriseIcon from '../action-menu/icon--surprise.svg'; const messages = defineMessages({ - addBackdrop: { - id: 'gui.stageSelector.targetPaneAddBackdrop', - description: 'Button to add a backdrop in the target pane', - defaultMessage: 'Add Backdrop' + addBackdropFromLibrary: { + id: 'gui.spriteSelector.addBackdropFromLibrary', + description: 'Button to add a stage in the target pane from library', + defaultMessage: 'Backdrop Library' + }, + addBackdropFromPaint: { + id: 'gui.stageSelector.addBackdropFromPaint', + description: 'Button to add a stage in the target pane from paint', + defaultMessage: 'Paint' + }, + addBackdropFromSurprise: { + id: 'gui.stageSelector.addBackdropFromSurprise', + description: 'Button to add a random stage in the target pane', + defaultMessage: 'Surprise' + }, + addBackdropFromFile: { + id: 'gui.stageSelector.addBackdropFromFile', + description: 'Button to add a stage in the target pane from file', + defaultMessage: 'Coming Soon' + }, + addBackdropFromCamera: { + id: 'gui.stageSelector.addBackdropFromCamera', + description: 'Button to add a stage in the target pane from camera', + defaultMessage: 'Coming Soon' } }); @@ -25,6 +50,8 @@ const StageSelector = props => { url, onClick, onNewBackdropClick, + onSurpriseBackdropClick, + onEmptyBackdropClick, ...componentProps } = props; return ( @@ -54,10 +81,28 @@ const StageSelector = props => { />
{backdropCount}
- @@ -68,8 +113,11 @@ StageSelector.propTypes = { backdropCount: PropTypes.number.isRequired, intl: intlShape.isRequired, onClick: PropTypes.func, + onEmptyBackdropClick: PropTypes.func, onNewBackdropClick: PropTypes.func, + onSurpriseBackdropClick: PropTypes.func, selected: PropTypes.bool.isRequired, url: PropTypes.string }; + export default injectIntl(StageSelector); diff --git a/src/components/target-pane/target-pane.jsx b/src/components/target-pane/target-pane.jsx index 30267688f..1044073a1 100644 --- a/src/components/target-pane/target-pane.jsx +++ b/src/components/target-pane/target-pane.jsx @@ -19,6 +19,7 @@ import styles from './target-pane.css'; const TargetPane = ({ backdropLibraryVisible, editingTarget, + hoveredTarget, spriteLibraryVisible, onChangeSpriteDirection, onChangeSpriteName, @@ -29,9 +30,12 @@ const TargetPane = ({ onDeleteSprite, onDuplicateSprite, onNewSpriteClick, + onSurpriseSpriteClick, + onPaintSpriteClick, onRequestCloseSpriteLibrary, onRequestCloseBackdropLibrary, onSelectSprite, + raiseSprites, stage, sprites, vm, @@ -43,6 +47,9 @@ const TargetPane = ({ >
{stage.id && { + this.setState({selectedCostumeIndex: costumeIndex + 1}); + }); + } handleNewCostume () { if (!this.props.vm.editingTarget) return; - const costumes = this.props.vm.editingTarget.sprite.costumes || []; + const costumes = this.props.vm.editingTarget.getCostumes() || []; this.setState({selectedCostumeIndex: Math.max(costumes.length - 1, 0)}); } handleNewBlankCostume () { @@ -97,6 +128,32 @@ class CostumeTab extends React.Component { this.handleNewCostume(); }); } + handleSurpriseCostume () { + const item = costumeLibraryContent[Math.floor(Math.random() * costumeLibraryContent.length)]; + const vmCostume = { + name: item.name, + rotationCenterX: item.info[0], + rotationCenterY: item.info[1], + bitmapResolution: item.info.length > 2 ? item.info[2] : 1, + skinId: null + }; + this.props.vm.addCostume(item.md5, vmCostume).then(() => { + this.handleNewCostume(); + }); + } + handleSurpriseBackdrop () { + const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)]; + const vmCostume = { + name: item.name, + rotationCenterX: item.info[0] && item.info[0] / 2, + rotationCenterY: item.info[1] && item.info[1] / 2, + bitmapResolution: item.info.length > 2 ? item.info[2] : 1, + skinId: null + }; + this.props.vm.addCostume(item.md5, vmCostume).then(() => { + this.handleNewCostume(); + }); + } render () { // For paint wrapper const { @@ -104,9 +161,7 @@ class CostumeTab extends React.Component { onNewLibraryBackdropClick, onNewLibraryCostumeClick, costumeLibraryVisible, - backdropLibraryVisible, onRequestCloseCostumeLibrary, - onRequestCloseBackdropLibrary, ...props } = this.props; @@ -124,7 +179,7 @@ class CostumeTab extends React.Component { } const addLibraryMessage = target.isStage ? messages.addLibraryBackdropMsg : messages.addLibraryCostumeMsg; - const addBlankMessage = target.isStage ? messages.addBlankBackdropMsg : messages.addBlankCostumeMsg; + const addSurpriseFunc = target.isStage ? this.handleSurpriseBackdrop : this.handleSurpriseCostume; const addLibraryFunc = target.isStage ? onNewLibraryBackdropClick : onNewLibraryCostumeClick; const addLibraryIcon = target.isStage ? addLibraryBackdropIcon : addLibraryCostumeIcon; @@ -132,19 +187,33 @@ class CostumeTab extends React.Component { 1 ? this.handleDeleteCostume : null} + onDuplicateClick={this.handleDuplicateCostume} onItemClick={this.handleSelectCostume} > {target.costumes ? @@ -161,13 +230,6 @@ class CostumeTab extends React.Component { onRequestClose={onRequestCloseCostumeLibrary} /> ) : null} - {backdropLibraryVisible ? ( - - ) : null} ); } @@ -186,7 +248,8 @@ CostumeTab.propTypes = { id: PropTypes.shape({ costumes: PropTypes.arrayOf(PropTypes.shape({ url: PropTypes.string, - name: PropTypes.string.isRequired + name: PropTypes.string.isRequired, + skinId: PropTypes.number })) }) }), @@ -215,9 +278,6 @@ const mapDispatchToProps = dispatch => ({ e.preventDefault(); dispatch(openCostumeLibrary()); }, - onRequestCloseBackdropLibrary: () => { - dispatch(closeBackdropLibrary()); - }, onRequestCloseCostumeLibrary: () => { dispatch(closeCostumeLibrary()); } diff --git a/src/containers/extension-library.jsx b/src/containers/extension-library.jsx index 9998fbb3e..d036caa23 100644 --- a/src/containers/extension-library.jsx +++ b/src/containers/extension-library.jsx @@ -7,7 +7,7 @@ import extensionLibraryContent from '../lib/libraries/extensions/index'; import analytics from '../lib/analytics'; import LibraryComponent from '../components/library/library.jsx'; -import extensionIcon from '../components/sprite-selector/icon--sprite.svg'; +import extensionIcon from '../components/action-menu/icon--sprite.svg'; class ExtensionLibrary extends React.PureComponent { constructor (props) { diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index 539705a58..de4fe27a2 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -2,23 +2,21 @@ import AudioEngine from 'scratch-audio'; import PropTypes from 'prop-types'; import React from 'react'; import VM from 'scratch-vm'; -import bindAll from 'lodash.bindall'; import {connect} from 'react-redux'; -import {openExtensionLibrary} from '../reducers/modals.js'; +import {openExtensionLibrary} from '../reducers/modals'; +import { + activateTab, + BLOCKS_TAB_INDEX, + COSTUMES_TAB_INDEX, + SOUNDS_TAB_INDEX +} from '../reducers/editor-tab'; import vmListenerHOC from '../lib/vm-listener-hoc.jsx'; import GUIComponent from '../components/gui/gui.jsx'; class GUI extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'handleTabSelect' - ]); - this.state = {tabIndex: 0}; - } componentDidMount () { this.audioEngine = new AudioEngine(); this.props.vm.attachAudioEngine(this.audioEngine); @@ -34,9 +32,6 @@ class GUI extends React.Component { componentWillUnmount () { this.props.vm.stopAll(); } - handleTabSelect (tabIndex) { - this.setState({tabIndex}); - } render () { const { children, @@ -46,10 +41,7 @@ class GUI extends React.Component { } = this.props; return ( {children} @@ -61,6 +53,7 @@ class GUI extends React.Component { GUI.propTypes = { ...GUIComponent.propTypes, feedbackFormVisible: PropTypes.bool, + importInfoVisible: PropTypes.bool, previewInfoVisible: PropTypes.bool, projectData: PropTypes.string, vm: PropTypes.instanceOf(VM) @@ -69,12 +62,18 @@ GUI.propTypes = { GUI.defaultProps = GUIComponent.defaultProps; const mapStateToProps = state => ({ + activeTabIndex: state.editorTab.activeTabIndex, + blocksTabVisible: state.editorTab.activeTabIndex === BLOCKS_TAB_INDEX, + costumesTabVisible: state.editorTab.activeTabIndex === COSTUMES_TAB_INDEX, feedbackFormVisible: state.modals.feedbackForm, - previewInfoVisible: state.modals.previewInfo + importInfoVisible: state.modals.importInfo, + previewInfoVisible: state.modals.previewInfo, + soundsTabVisible: state.editorTab.activeTabIndex === SOUNDS_TAB_INDEX }); const mapDispatchToProps = dispatch => ({ - onExtensionButtonClick: () => dispatch(openExtensionLibrary()) + onExtensionButtonClick: () => dispatch(openExtensionLibrary()), + onActivateTab: tab => dispatch(activateTab(tab)) }); const ConnectedGUI = connect( diff --git a/src/containers/import-modal.jsx b/src/containers/import-modal.jsx new file mode 100644 index 000000000..1c2ddbefb --- /dev/null +++ b/src/containers/import-modal.jsx @@ -0,0 +1,111 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; + +import ImportModalComponent from '../components/import-modal/import-modal.jsx'; + +import { + closeImportInfo, + openPreviewInfo +} from '../reducers/modals'; + +class ImportModal extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleViewProject', + 'handleCancel', + 'handleChange', + 'handleGoBack', + 'handleKeyPress' + ]); + + this.state = { + inputValue: '', + hasValidationError: false, + errorMessage: '' + }; + } + handleKeyPress (event) { + if (event.key === 'Enter') this.handleViewProject(); + } + handleViewProject () { + const inputValue = this.state.inputValue; + const projectId = this.validate(inputValue); + + if (projectId) { + const projectLink = document.createElement('a'); + document.body.appendChild(projectLink); + projectLink.href = `#${projectId}`; + projectLink.click(); + document.body.removeChild(projectLink); + this.props.onViewProject(); + } else { + this.setState({ + hasValidationError: true, + errorMessage: `invalidFormatError`}); + } + } + handleChange (e) { + this.setState({inputValue: e.target.value, hasValidationError: false}); + } + validate (input) { + const urlMatches = input.match(/^(?:https:\/\/)?scratch\.mit\.edu\/projects\/(\d+)/); + if (urlMatches && urlMatches.length > 0) { + return urlMatches[1]; + } + const projectIdMatches = input.match(/^#?(\d+)$/); + if (projectIdMatches && projectIdMatches.length > 0) { + return projectIdMatches[1]; + } + return null; + } + handleCancel () { + this.props.onCancel(); + } + handleGoBack () { + this.props.onBack(); + } + render () { + return ( + + ); + } +} + +ImportModal.propTypes = { + onBack: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + onViewProject: PropTypes.func +}; + +const mapStateToProps = () => ({}); + +const mapDispatchToProps = dispatch => ({ + onBack: () => { + dispatch(closeImportInfo()); + dispatch(openPreviewInfo()); + }, + onCancel: () => { + dispatch(closeImportInfo()); + }, + onViewProject: () => { + dispatch(closeImportInfo()); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(ImportModal); diff --git a/src/containers/preview-modal.jsx b/src/containers/preview-modal.jsx index 8d50dc83c..1209f251f 100644 --- a/src/containers/preview-modal.jsx +++ b/src/containers/preview-modal.jsx @@ -8,7 +8,8 @@ import PreviewModalComponent from '../components/preview-modal/preview-modal.jsx import BrowserModalComponent from '../components/browser-modal/browser-modal.jsx'; import { - closePreviewInfo + closePreviewInfo, + openImportInfo } from '../reducers/modals'; class PreviewModal extends React.Component { @@ -16,7 +17,8 @@ class PreviewModal extends React.Component { super(props); bindAll(this, [ 'handleTryIt', - 'handleCancel' + 'handleCancel', + 'handleViewProject' ]); this.state = { @@ -30,6 +32,9 @@ class PreviewModal extends React.Component { handleCancel () { window.location.replace('https://scratch.mit.edu'); } + handleViewProject () { + this.props.onViewProject(); + } supportedBrowser () { if (platform.name === 'IE') { return false; @@ -42,6 +47,7 @@ class PreviewModal extends React.Component { previewing={this.state.previewing} onCancel={this.handleCancel} onTryIt={this.handleTryIt} + onViewProject={this.handleViewProject} /> : ({}); @@ -59,6 +66,10 @@ const mapStateToProps = () => ({}); const mapDispatchToProps = dispatch => ({ onTryIt: () => { dispatch(closePreviewInfo()); + }, + onViewProject: () => { + dispatch(closePreviewInfo()); + dispatch(openImportInfo()); } }); diff --git a/src/containers/sound-editor.jsx b/src/containers/sound-editor.jsx index 781b2cc5f..bbe032c0b 100644 --- a/src/containers/sound-editor.jsx +++ b/src/containers/sound-editor.jsx @@ -1,6 +1,7 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; +import WavEncoder from 'wav-encoder'; import {connect} from 'react-redux'; @@ -9,6 +10,7 @@ import {computeChunkedRMS} from '../lib/audio/audio-util.js'; import AudioEffects from '../lib/audio/audio-effects.js'; import SoundEditorComponent from '../components/sound-editor/sound-editor.jsx'; import AudioBufferPlayer from '../lib/audio/audio-buffer-player.js'; +import log from '../lib/log.js'; const UNDO_STACK_SIZE = 99; @@ -72,11 +74,25 @@ class SoundEditor extends React.Component { } this.undoStack.push(this.copyCurrentBuffer()); } + // Encode the new sound into a wav so that it can be stored + let wavBuffer = null; + try { + wavBuffer = WavEncoder.encode.sync({ + sampleRate: sampleRate, + channelData: [samples] + }); + } catch (e) { + // This error state is mostly for the mock sounds used during testing. + // Any incorrect sound buffer trying to get interpretd as a Wav file + // should yield this error. + log.error(`Encountered error while trying to encode sound update: ${e}`); + } + this.resetState(samples, sampleRate); this.props.onUpdateSoundBuffer( this.props.soundIndex, - this.audioBufferPlayer.buffer - ); + this.audioBufferPlayer.buffer, + wavBuffer ? new Uint8Array(wavBuffer) : new Uint8Array()); } handlePlay () { this.audioBufferPlayer.play( diff --git a/src/containers/sound-tab.jsx b/src/containers/sound-tab.jsx index 9e30ba2ed..471ebe99e 100644 --- a/src/containers/sound-tab.jsx +++ b/src/containers/sound-tab.jsx @@ -8,10 +8,15 @@ import AssetPanel from '../components/asset-panel/asset-panel.jsx'; import soundIcon from '../components/asset-panel/icon--sound.svg'; import addSoundFromLibraryIcon from '../components/asset-panel/icon--add-sound-lib.svg'; import addSoundFromRecordingIcon from '../components/asset-panel/icon--add-sound-record.svg'; +import fileUploadIcon from '../components/action-menu/icon--file-upload.svg'; +import surpriseIcon from '../components/action-menu/icon--surprise.svg'; + import RecordModal from './record-modal.jsx'; import SoundEditor from './sound-editor.jsx'; import SoundLibrary from './sound-library.jsx'; +import soundLibraryContent from '../lib/libraries/sounds.json'; + import {connect} from 'react-redux'; import { @@ -26,7 +31,9 @@ class SoundTab extends React.Component { bindAll(this, [ 'handleSelectSound', 'handleDeleteSound', - 'handleNewSound' + 'handleDuplicateSound', + 'handleNewSound', + 'handleSurpriseSound' ]); this.state = {selectedSoundIndex: 0}; } @@ -51,6 +58,15 @@ class SoundTab extends React.Component { handleDeleteSound (soundIndex) { this.props.vm.deleteSound(soundIndex); + if (soundIndex >= this.state.selectedSoundIndex) { + this.setState({selectedSoundIndex: Math.max(0, soundIndex - 1)}); + } + } + + handleDuplicateSound (soundIndex) { + this.props.vm.duplicateSound(soundIndex).then(() => { + this.setState({selectedSoundIndex: soundIndex + 1}); + }); } handleNewSound () { @@ -62,6 +78,20 @@ class SoundTab extends React.Component { this.setState({selectedSoundIndex: Math.max(sounds.length - 1, 0)}); } + handleSurpriseSound () { + const soundItem = soundLibraryContent[Math.floor(Math.random() * soundLibraryContent.length)]; + const vmSound = { + format: soundItem.format, + md5: soundItem.md5, + rate: soundItem.rate, + sampleCount: soundItem.sampleCount, + name: soundItem.name + }; + this.props.vm.addSound(vmSound).then(() => { + this.handleNewSound(); + }); + } + render () { const { intl, @@ -84,28 +114,45 @@ class SoundTab extends React.Component { )) : []; const messages = defineMessages({ + fileUploadSound: { + defaultMessage: 'Coming Soon', + description: 'Button to upload sound from file in the editor tab', + id: 'gui.soundTab.fileUploadSound' + }, + surpriseSound: { + defaultMessage: 'Surprise', + description: 'Button to get a random sound in the editor tab', + id: 'gui.soundTab.surpriseSound' + }, recordSound: { - defaultMessage: 'Record Sound', + defaultMessage: 'Record', description: 'Button to record a sound in the editor tab', id: 'gui.soundTab.recordSound' }, addSound: { - defaultMessage: 'Add Sound', + defaultMessage: 'Sound Library', description: 'Button to add a sound in the editor tab', - id: 'gui.soundTab.addSound' + id: 'gui.soundTab.addSoundFromLibrary' } }); return ( ({ url: soundIcon, @@ -113,6 +160,7 @@ class SoundTab extends React.Component { }))} selectedItemIndex={this.state.selectedSoundIndex} onDeleteClick={this.handleDeleteSound} + onDuplicateClick={this.handleDuplicateSound} onItemClick={this.handleSelectSound} > {sprite.sounds && sprite.sounds[this.state.selectedSoundIndex] ? ( diff --git a/src/containers/sprite-selector-item.jsx b/src/containers/sprite-selector-item.jsx index 9879d8003..edb124b2c 100644 --- a/src/containers/sprite-selector-item.jsx +++ b/src/containers/sprite-selector-item.jsx @@ -1,9 +1,10 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; - import {connect} from 'react-redux'; +import {setHoveredSprite} from '../reducers/hovered-target'; + import SpriteSelectorItemComponent from '../components/sprite-selector-item/sprite-selector-item.jsx'; class SpriteSelectorItem extends React.Component { @@ -12,14 +13,17 @@ class SpriteSelectorItem extends React.Component { bindAll(this, [ 'handleClick', 'handleDelete', - 'handleDuplicate' + 'handleDuplicate', + 'handleMouseEnter', + 'handleMouseLeave' ]); } handleClick (e) { e.preventDefault(); this.props.onClick(this.props.id); } - handleDelete () { + handleDelete (e) { + e.stopPropagation(); // To prevent from bubbling back to handleClick // @todo add i18n here // eslint-disable-next-line no-alert if (window.confirm('Are you sure you want to delete this?')) { @@ -30,6 +34,12 @@ class SpriteSelectorItem extends React.Component { e.stopPropagation(); // To prevent from bubbling back to handleClick this.props.onDuplicateButtonClick(this.props.id); } + handleMouseLeave () { + this.props.dispatchSetHoveredSprite(null); + } + handleMouseEnter () { + this.props.dispatchSetHoveredSprite(this.props.id); + } render () { const { /* eslint-disable no-unused-vars */ @@ -38,6 +48,7 @@ class SpriteSelectorItem extends React.Component { onClick, onDeleteButtonClick, onDuplicateButtonClick, + receivedBlocks, /* eslint-enable no-unused-vars */ ...props } = this.props; @@ -46,6 +57,8 @@ class SpriteSelectorItem extends React.Component { onClick={this.handleClick} onDeleteButtonClick={onDeleteButtonClick ? this.handleDelete : null} onDuplicateButtonClick={onDuplicateButtonClick ? this.handleDuplicate : null} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} {...props} /> ); @@ -55,18 +68,28 @@ class SpriteSelectorItem extends React.Component { SpriteSelectorItem.propTypes = { assetId: PropTypes.string, costumeURL: PropTypes.string, + dispatchSetHoveredSprite: PropTypes.func.isRequired, id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), name: PropTypes.string, onClick: PropTypes.func, onDeleteButtonClick: PropTypes.func, onDuplicateButtonClick: PropTypes.func, + receivedBlocks: PropTypes.bool.isRequired, selected: PropTypes.bool }; -const mapStateToProps = (state, {assetId, costumeURL}) => ({ - costumeURL: costumeURL || (assetId && state.vm.runtime.storage.get(assetId).encodeDataURI()) +const mapStateToProps = (state, {assetId, costumeURL, id}) => ({ + costumeURL: costumeURL || (assetId && state.vm.runtime.storage.get(assetId).encodeDataURI()), + receivedBlocks: state.hoveredTarget.receivedBlocks && + state.hoveredTarget.sprite === id +}); +const mapDispatchToProps = dispatch => ({ + dispatchSetHoveredSprite: spriteId => { + dispatch(setHoveredSprite(spriteId)); + } }); export default connect( - mapStateToProps + mapStateToProps, + mapDispatchToProps )(SpriteSelectorItem); diff --git a/src/containers/stage-selector.jsx b/src/containers/stage-selector.jsx index 83a5a1bb0..27075cef8 100644 --- a/src/containers/stage-selector.jsx +++ b/src/containers/stage-selector.jsx @@ -4,24 +4,57 @@ import React from 'react'; import {connect} from 'react-redux'; import {openBackdropLibrary} from '../reducers/modals'; +import {activateTab, COSTUMES_TAB_INDEX} from '../reducers/editor-tab'; + import StageSelectorComponent from '../components/stage-selector/stage-selector.jsx'; +import backdropLibraryContent from '../lib/libraries/backdrops.json'; +import costumeLibraryContent from '../lib/libraries/costumes.json'; + class StageSelector extends React.Component { constructor (props) { super(props); bindAll(this, [ - 'handleClick' + 'handleClick', + 'handleSurpriseBackdrop', + 'handleEmptyBackdrop', + 'addBackdropFromLibraryItem' ]); } + addBackdropFromLibraryItem (item) { + const vmBackdrop = { + name: item.name, + rotationCenterX: item.info[0] && item.info[0] / 2, + rotationCenterY: item.info[1] && item.info[1] / 2, + bitmapResolution: item.info.length > 2 ? item.info[2] : 1, + skinId: null + }; + return this.props.vm.addBackdrop(item.md5, vmBackdrop); + } handleClick (e) { e.preventDefault(); this.props.onSelect(this.props.id); } + handleSurpriseBackdrop () { + // @todo should this not add a backdrop you already have? + const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)]; + this.addBackdropFromLibraryItem(item); + } + handleEmptyBackdrop () { + // @todo this is brittle, will need to be refactored for localized libraries + const emptyItem = costumeLibraryContent.find(item => item.name === 'Empty'); + if (emptyItem) { + this.addBackdropFromLibraryItem(emptyItem).then(() => { + this.props.onActivateTab(COSTUMES_TAB_INDEX); + }); + } + } render () { const { /* eslint-disable no-unused-vars */ assetId, id, + onActivateTab, onSelect, /* eslint-enable no-unused-vars */ ...componentProps @@ -29,6 +62,8 @@ class StageSelector extends React.Component { return ( ); @@ -41,13 +76,17 @@ StageSelector.propTypes = { }; const mapStateToProps = (state, {assetId}) => ({ - url: assetId && state.vm.runtime.storage.get(assetId).encodeDataURI() + url: assetId && state.vm.runtime.storage.get(assetId).encodeDataURI(), + vm: state.vm }); const mapDispatchToProps = dispatch => ({ onNewBackdropClick: e => { e.preventDefault(); dispatch(openBackdropLibrary()); + }, + onActivateTab: tabIndex => { + dispatch(activateTab(tabIndex)); } }); diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx index 5095f86dd..c386112b3 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -31,6 +31,7 @@ class Stage extends React.Component { 'onMouseDown', 'onStartDrag', 'onStopDrag', + 'onWheel', 'updateRect', 'questionListener', 'setCanvas' @@ -98,6 +99,7 @@ class Stage extends React.Component { document.addEventListener('touchend', this.onMouseUp); canvas.addEventListener('mousedown', this.onMouseDown); canvas.addEventListener('touchstart', this.onMouseDown); + canvas.addEventListener('wheel', this.onWheel); } detachMouseEvents (canvas) { document.removeEventListener('mousemove', this.onMouseMove); @@ -106,6 +108,7 @@ class Stage extends React.Component { document.removeEventListener('touchend', this.onMouseUp); canvas.removeEventListener('mousedown', this.onMouseDown); canvas.removeEventListener('touchstart', this.onMouseDown); + canvas.removeEventListener('wheel', this.onWheel); } attachRectEvents () { window.addEventListener('resize', this.updateRect); @@ -232,6 +235,13 @@ class Stage extends React.Component { this.setState({colorInfo: null}); } } + onWheel (e) { + const data = { + deltaX: e.deltaX, + deltaY: e.deltaY + }; + this.props.vm.postIOData('mouseWheel', data); + } cancelMouseDownTimeout () { if (this.state.mouseDownTimeoutId !== null) { clearTimeout(this.state.mouseDownTimeoutId); diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx index 32ad448bc..889d84185 100644 --- a/src/containers/target-pane.jsx +++ b/src/containers/target-pane.jsx @@ -9,12 +9,17 @@ import { closeSpriteLibrary } from '../reducers/modals'; +import {activateTab, COSTUMES_TAB_INDEX} from '../reducers/editor-tab'; +import {setReceivedBlocks} from '../reducers/hovered-target'; + import TargetPaneComponent from '../components/target-pane/target-pane.jsx'; +import spriteLibraryContent from '../lib/libraries/sprites.json'; class TargetPane extends React.Component { constructor (props) { super(props); bindAll(this, [ + 'handleBlockDragEnd', 'handleChangeSpriteDirection', 'handleChangeSpriteName', 'handleChangeSpriteSize', @@ -23,9 +28,17 @@ class TargetPane extends React.Component { 'handleChangeSpriteY', 'handleDeleteSprite', 'handleDuplicateSprite', - 'handleSelectSprite' + 'handleSelectSprite', + 'handleSurpriseSpriteClick', + 'handlePaintSpriteClick' ]); } + componentDidMount () { + this.props.vm.addListener('BLOCK_DRAG_END', this.handleBlockDragEnd); + } + componentWillUnmount () { + this.props.vm.removeListener('BLOCK_DRAG_END', this.handleBlockDragEnd); + } handleChangeSpriteDirection (direction) { this.props.vm.postSpriteInfo({direction}); } @@ -53,10 +66,33 @@ class TargetPane extends React.Component { handleSelectSprite (id) { this.props.vm.setEditingTarget(id); } + handleSurpriseSpriteClick () { + const item = spriteLibraryContent[Math.floor(Math.random() * spriteLibraryContent.length)]; + this.props.vm.addSprite2(JSON.stringify(item.json)); + } + handlePaintSpriteClick () { + // @todo this is brittle, will need to be refactored for localized libraries + const emptyItem = spriteLibraryContent.find(item => item.name === 'Empty'); + if (emptyItem) { + this.props.vm.addSprite2(JSON.stringify(emptyItem.json)).then(() => { + this.props.onActivateTab(COSTUMES_TAB_INDEX); + }); + } + } + handleBlockDragEnd (blocks) { + if (this.props.hoveredTarget.sprite && this.props.hoveredTarget.sprite !== this.props.editingTarget) { + this.props.vm.shareBlocksToTarget(blocks, this.props.hoveredTarget.sprite); + this.props.onReceivedBlocks(true); + } + } render () { + const { + onActivateTab, // eslint-disable-line no-unused-vars + ...componentProps + } = this.props; return ( ); } @@ -82,6 +120,7 @@ TargetPane.propTypes = { const mapStateToProps = state => ({ editingTarget: state.targets.editingTarget, + hoveredTarget: state.hoveredTarget, sprites: Object.keys(state.targets.sprites).reduce((sprites, k) => { let {direction, size, x, y, ...sprite} = state.targets.sprites[k]; if (typeof direction !== 'undefined') direction = Math.round(direction); @@ -92,6 +131,7 @@ const mapStateToProps = state => ({ return sprites; }, {}), stage: state.targets.stage, + raiseSprites: state.blockDrag, spriteLibraryVisible: state.modals.spriteLibrary, backdropLibraryVisible: state.modals.backdropLibrary }); @@ -105,6 +145,12 @@ const mapDispatchToProps = dispatch => ({ }, onRequestCloseBackdropLibrary: () => { dispatch(closeBackdropLibrary()); + }, + onActivateTab: tabIndex => { + dispatch(activateTab(tabIndex)); + }, + onReceivedBlocks: receivedBlocks => { + dispatch(setReceivedBlocks(receivedBlocks)); } }); diff --git a/src/css/colors.css b/src/css/colors.css index e270217f9..be8a9a0db 100644 --- a/src/css/colors.css +++ b/src/css/colors.css @@ -18,4 +18,8 @@ $control-primary: #FFAB19; $data-primary: #FF8C1A; +$pen-primary: #11B581; + $form-border: #E9EEF2; + +$import-primary: #0FBD8C; diff --git a/src/lib/blocks.js b/src/lib/blocks.js index af15abc87..06c9a459c 100644 --- a/src/lib/blocks.js +++ b/src/lib/blocks.js @@ -36,15 +36,15 @@ export default function (vm) { }; const costumesMenu = function () { - if (vm.editingTarget && vm.editingTarget.sprite.costumes.length > 0) { - return vm.editingTarget.sprite.costumes.map(costume => [costume.name, costume.name]); + if (vm.editingTarget && vm.editingTarget.getCostumes().length > 0) { + return vm.editingTarget.getCostumes().map(costume => [costume.name, costume.name]); } return [['', '']]; }; const backdropsMenu = function () { - if (vm.runtime.targets[0] && vm.runtime.targets[0].sprite.costumes.length > 0) { - return vm.runtime.targets[0].sprite.costumes.map(costume => [costume.name, costume.name]) + if (vm.runtime.targets[0] && vm.runtime.targets[0].getCostumes().length > 0) { + return vm.runtime.targets[0].getCostumes().map(costume => [costume.name, costume.name]) .concat([['next backdrop', 'next backdrop'], ['previous backdrop', 'previous backdrop']]); } return [['', '']]; diff --git a/src/lib/opcode-labels.js b/src/lib/opcode-labels.js index c4fa07bcf..e877a2a51 100644 --- a/src/lib/opcode-labels.js +++ b/src/lib/opcode-labels.js @@ -37,10 +37,10 @@ const opcodeMap = { labelFn: params => params.VARIABLE }, data_listcontents: { - category: 'data', + category: 'list', labelFn: params => params.LIST }, - + // Sound sound_volume: { category: 'sound', diff --git a/src/lib/vm-listener-hoc.jsx b/src/lib/vm-listener-hoc.jsx index 081817c78..8e7a0ca17 100644 --- a/src/lib/vm-listener-hoc.jsx +++ b/src/lib/vm-listener-hoc.jsx @@ -6,6 +6,7 @@ import VM from 'scratch-vm'; import {connect} from 'react-redux'; import {updateEditingTarget, updateTargets} from '../reducers/targets'; +import {updateBlockDrag} from '../reducers/block-drag'; import {updateMonitors} from '../reducers/monitors'; /* @@ -29,7 +30,7 @@ const vmListenerHOC = function (WrappedComponent) { // we need to start listening before mounting the wrapped component. this.props.vm.on('targetsUpdate', this.props.onTargetsUpdate); this.props.vm.on('MONITORS_UPDATE', this.props.onMonitorsUpdate); - + this.props.vm.on('BLOCK_DRAG_UPDATE', this.props.onBlockDragUpdate); } componentDidMount () { if (this.props.attachKeyboardEvents) { @@ -74,6 +75,7 @@ const vmListenerHOC = function (WrappedComponent) { const { /* eslint-disable no-unused-vars */ attachKeyboardEvents, + onBlockDragUpdate, onKeyDown, onKeyUp, onMonitorsUpdate, @@ -86,17 +88,20 @@ const vmListenerHOC = function (WrappedComponent) { } VMListener.propTypes = { attachKeyboardEvents: PropTypes.bool, + onBlockDragUpdate: PropTypes.func.isRequired, onKeyDown: PropTypes.func, onKeyUp: PropTypes.func, - onMonitorsUpdate: PropTypes.func, - onTargetsUpdate: PropTypes.func, + onMonitorsUpdate: PropTypes.func.isRequired, + onTargetsUpdate: PropTypes.func.isRequired, vm: PropTypes.instanceOf(VM).isRequired }; VMListener.defaultProps = { attachKeyboardEvents: true }; const mapStateToProps = state => ({ - vm: state.vm + vm: state.vm, + hoveredSprite: state.hoveredTarget.sprite, + editingTarget: state.targets.editingTarget }); const mapDispatchToProps = dispatch => ({ onTargetsUpdate: data => { @@ -105,6 +110,9 @@ const vmListenerHOC = function (WrappedComponent) { }, onMonitorsUpdate: monitorList => { dispatch(updateMonitors(monitorList)); + }, + onBlockDragUpdate: areBlocksOverGui => { + dispatch(updateBlockDrag(areBlocksOverGui)); } }); return connect( diff --git a/src/reducers/block-drag.js b/src/reducers/block-drag.js new file mode 100644 index 000000000..5a261ceaa --- /dev/null +++ b/src/reducers/block-drag.js @@ -0,0 +1,28 @@ +const BLOCK_DRAG_UPDATE = 'scratch-gui/block-drag/BLOCK_DRAG_UPDATE'; + +const initialState = false; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case BLOCK_DRAG_UPDATE: + return action.areBlocksOverGui; + default: + return state; + } +}; + +const updateBlockDrag = function (areBlocksOverGui) { + return { + type: BLOCK_DRAG_UPDATE, + areBlocksOverGui: areBlocksOverGui, + meta: { + throttle: 30 + } + }; +}; + +export { + reducer as default, + updateBlockDrag +}; diff --git a/src/reducers/editor-tab.js b/src/reducers/editor-tab.js new file mode 100644 index 000000000..21b3534e2 --- /dev/null +++ b/src/reducers/editor-tab.js @@ -0,0 +1,37 @@ +const ACTIVATE_TAB = 'scratch-gui/navigation/ACTIVATE_TAB'; + +// Constants use numbers to make it easier to work with react-tabs +const BLOCKS_TAB_INDEX = 0; +const COSTUMES_TAB_INDEX = 1; +const SOUNDS_TAB_INDEX = 2; + +const initialState = { + activeTabIndex: BLOCKS_TAB_INDEX +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case ACTIVATE_TAB: + return Object.assign({}, state, { + activeTabIndex: action.activeTabIndex + }); + default: + return state; + } +}; + +const activateTab = function (tab) { + return { + type: ACTIVATE_TAB, + activeTabIndex: tab + }; +}; + +export { + reducer as default, + activateTab, + BLOCKS_TAB_INDEX, + COSTUMES_TAB_INDEX, + SOUNDS_TAB_INDEX +}; diff --git a/src/reducers/gui.js b/src/reducers/gui.js index d49144b4f..d89a195c9 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -1,6 +1,9 @@ import {combineReducers} from 'redux'; import colorPickerReducer from './color-picker'; import customProceduresReducer from './custom-procedures'; +import blockDragReducer from './block-drag'; +import editorTabReducer from './editor-tab'; +import hoveredTargetReducer from './hovered-target'; import intlReducer from './intl'; import modalReducer from './modals'; import monitorReducer from './monitors'; @@ -12,8 +15,11 @@ import stageSizeReducer from './stage-size'; import {ScratchPaintReducer} from 'scratch-paint'; export default combineReducers({ + blockDrag: blockDragReducer, colorPicker: colorPickerReducer, customProcedures: customProceduresReducer, + editorTab: editorTabReducer, + hoveredTarget: hoveredTargetReducer, intl: intlReducer, stageSize: stageSizeReducer, modals: modalReducer, diff --git a/src/reducers/hovered-target.js b/src/reducers/hovered-target.js new file mode 100644 index 000000000..43bf87ac0 --- /dev/null +++ b/src/reducers/hovered-target.js @@ -0,0 +1,48 @@ +const SET_HOVERED_SPRITE = 'scratch-gui/hovered-target/SET_HOVERED_SPRITE'; +const SET_RECEIVED_BLOCKS = 'scratch-gui/hovered-target/SET_RECEIVED_BLOCKS'; + +const initialState = { + sprite: null, + receivedBlocks: false +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case SET_HOVERED_SPRITE: + return { + sprite: action.spriteId, + receivedBlocks: false + }; + case SET_RECEIVED_BLOCKS: + return { + sprite: state.sprite, + receivedBlocks: action.receivedBlocks + }; + default: + return state; + } +}; + +const setHoveredSprite = function (spriteId) { + return { + type: SET_HOVERED_SPRITE, + spriteId: spriteId, + meta: { + throttle: 30 + } + }; +}; + +const setReceivedBlocks = function (receivedBlocks) { + return { + type: SET_RECEIVED_BLOCKS, + receivedBlocks: receivedBlocks + }; +}; + +export { + reducer as default, + setHoveredSprite, + setReceivedBlocks +}; diff --git a/src/reducers/modals.js b/src/reducers/modals.js index ffbca3f8c..ba038ff34 100644 --- a/src/reducers/modals.js +++ b/src/reducers/modals.js @@ -7,6 +7,7 @@ const MODAL_BACKDROP_LIBRARY = 'backdropLibrary'; const MODAL_COSTUME_LIBRARY = 'costumeLibrary'; const MODAL_EXTENSION_LIBRARY = 'extensionLibrary'; const MODAL_FEEDBACK_FORM = 'feedbackForm'; +const MODAL_IMPORT_INFO = 'importInfo'; const MODAL_PREVIEW_INFO = 'previewInfo'; const MODAL_SOUND_LIBRARY = 'soundLibrary'; const MODAL_SPRITE_LIBRARY = 'spriteLibrary'; @@ -18,6 +19,7 @@ const initialState = { [MODAL_COSTUME_LIBRARY]: false, [MODAL_EXTENSION_LIBRARY]: false, [MODAL_FEEDBACK_FORM]: false, + [MODAL_IMPORT_INFO]: false, [MODAL_PREVIEW_INFO]: true, [MODAL_SOUND_LIBRARY]: false, [MODAL_SPRITE_LIBRARY]: false, @@ -67,6 +69,14 @@ const openFeedbackForm = function () { analytics.pageview('/modals/feedback'); return openModal(MODAL_FEEDBACK_FORM); }; +const openImportInfo = function () { + analytics.pageview('modals/import'); + return openModal(MODAL_IMPORT_INFO); +}; +const openPreviewInfo = function () { + analytics.pageview('/modals/preview'); + return openModal(MODAL_PREVIEW_INFO); +}; const openSoundLibrary = function () { analytics.pageview('/libraries/sounds'); return openModal(MODAL_SOUND_LIBRARY); @@ -79,10 +89,6 @@ const openSoundRecorder = function () { analytics.pageview('/modals/microphone'); return openModal(MODAL_SOUND_RECORDER); }; -const openPreviewInfo = function () { - analytics.pageview('/modals/preview'); - return openModal(MODAL_PREVIEW_INFO); -}; const closeBackdropLibrary = function () { return closeModal(MODAL_BACKDROP_LIBRARY); }; @@ -95,6 +101,9 @@ const closeExtensionLibrary = function () { const closeFeedbackForm = function () { return closeModal(MODAL_FEEDBACK_FORM); }; +const closeImportInfo = function () { + return closeModal(MODAL_IMPORT_INFO); +}; const closePreviewInfo = function () { return closeModal(MODAL_PREVIEW_INFO); }; @@ -113,6 +122,7 @@ export { openCostumeLibrary, openExtensionLibrary, openFeedbackForm, + openImportInfo, openPreviewInfo, openSoundLibrary, openSpriteLibrary, @@ -121,6 +131,7 @@ export { closeCostumeLibrary, closeExtensionLibrary, closeFeedbackForm, + closeImportInfo, closePreviewInfo, closeSpriteLibrary, closeSoundLibrary, diff --git a/test/helpers/selenium-helper.js b/test/helpers/selenium-helper.js index c8ead9072..b63ea2bf6 100644 --- a/test/helpers/selenium-helper.js +++ b/test/helpers/selenium-helper.js @@ -25,6 +25,7 @@ class SeleniumHelper { // List of useful xpath scopes for finding elements return { blocksTab: "*[@id='react-tabs-1']", + costumesTab: "*[@id='react-tabs-3']", modal: '*[@class="ReactModalPortal"]', reportedValue: '*[@class="blocklyDropDownContent"]', soundsTab: "*[@id='react-tabs-5']", diff --git a/test/integration/costumes.test.js b/test/integration/costumes.test.js index 98f31629a..00700cba2 100644 --- a/test/integration/costumes.test.js +++ b/test/integration/costumes.test.js @@ -7,7 +7,9 @@ const { findByXpath, getDriver, getLogs, - loadUri + loadUri, + rightClickText, + scope } = new SeleniumHelper(); const uri = path.resolve(__dirname, '../../build/index.html'); @@ -27,7 +29,7 @@ describe('Working with costumes', () => { await loadUri(uri); await clickXpath('//button[@title="tryit"]'); await clickText('Costumes'); - await clickXpath('//button[@title="Add Costume"]'); + await clickXpath('//button[@aria-label="Costume Library"]'); const el = await findByXpath("//input[@placeholder='what are you looking for?']"); await el.sendKeys('abb'); await clickText('Abby-a'); // Should close the modal, then click the costumes in the selector @@ -36,10 +38,26 @@ describe('Working with costumes', () => { await expect(logs).toEqual([]); }); + test('Duplicating a costume', async () => { + await loadUri(uri); + await clickXpath('//button[@title="tryit"]'); + await clickText('Costumes'); + + await rightClickText('costume1', scope.costumesTab); + await clickText('duplicate', scope.costumesTab); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for duplication to finish + + // Make sure the duplicated costume is named correctly. + await clickText('costume3', scope.costumesTab); + + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); + test('Adding a backdrop', async () => { await loadUri(uri); await clickXpath('//button[@title="tryit"]'); - await clickXpath('//button[@title="Add Backdrop"]'); + await clickXpath('//button[@aria-label="Backdrop Library"]'); const el = await findByXpath("//input[@placeholder='what are you looking for?']"); await el.sendKeys('blue'); await clickText('Blue Sky'); // Should close the modal diff --git a/test/integration/project-loading.test.js b/test/integration/project-loading.test.js index 9d5cb0384..6add67c42 100644 --- a/test/integration/project-loading.test.js +++ b/test/integration/project-loading.test.js @@ -4,6 +4,7 @@ import SeleniumHelper from '../helpers/selenium-helper'; const { clickText, clickXpath, + findByXpath, getDriver, getLogs, loadUri @@ -31,7 +32,40 @@ describe('Loading scratch gui', () => { describe('Loading projects by ID', () => { - test('Load a project by ID', async () => { + test('Load 2.0 project using import modal', async () => { + await loadUri(uri); + await clickText('View 2.0 Project'); + const el = await findByXpath("//input[@placeholder='scratch.mit.edu/projects/123456789']"); + const projectId = '96708228'; + await el.sendKeys(`scratch.mit.edu/projects/${projectId}`); + await clickXpath('//button[@title="viewproject"]'); + 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(); + await expect(logs).toEqual([]); + }); + + test('Invalid url when loading project through modal lets you try again', async () => { + await loadUri(uri); + await clickText('View 2.0 Project'); + let el = await findByXpath("//input[@placeholder='scratch.mit.edu/projects/123456789']"); + await el.sendKeys('thisisnotaurl'); + await clickXpath('//button[@title="viewproject"]'); + el = await findByXpath("//input[@placeholder='scratch.mit.edu/projects/123456789']"); + await el.clear(); + await el.sendKeys('scratch.mit.edu/projects/96708228'); + await clickXpath('//button[@title="viewproject"]'); + 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(); + await expect(logs).toEqual([]); + }); + + test('Load a project by ID directly through url', async () => { const projectId = '96708228'; await loadUri(`${uri}#${projectId}`); await clickXpath('//button[@title="tryit"]'); diff --git a/test/integration/sounds.test.js b/test/integration/sounds.test.js index 244ebb9b4..fc36c55a5 100644 --- a/test/integration/sounds.test.js +++ b/test/integration/sounds.test.js @@ -37,13 +37,13 @@ describe('Working with sounds', () => { .accept(); // Add it back - await clickXpath('//button[@title="Add Sound"]'); + await clickXpath('//button[@aria-label="Sound Library"]'); let el = await findByXpath("//input[@placeholder='what are you looking for?']"); await el.sendKeys('meow'); await clickText('Meow', scope.modal); // Should close the modal // Add a new sound - await clickXpath('//button[@title="Add Sound"]'); + await clickXpath('//button[@aria-label="Sound Library"]'); el = await findByXpath("//input[@placeholder='what are you looking for?']"); await el.sendKeys('chom'); await clickText('Chomp'); // Should close the modal, then click the sounds in the selector @@ -63,6 +63,22 @@ describe('Working with sounds', () => { await expect(logs).toEqual([]); }); + test('Duplicating a sound', async () => { + await loadUri(uri); + await clickXpath('//button[@title="tryit"]'); + await clickText('Sounds'); + + await rightClickText('Meow', scope.soundsTab); + await clickText('duplicate', scope.soundsTab); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for error + + // Make sure the duplicated sound is named correctly. + await clickText('Meow2', scope.soundsTab); + + const logs = await getLogs(); + await expect(logs).toEqual([]); + }); + // Regression test for gui issue #1320 test('Switching sprites with different numbers of sounds', async () => { await loadUri(uri); @@ -70,11 +86,11 @@ describe('Working with sounds', () => { // Add a sound so this sprite has 2 sounds. await clickText('Sounds'); - await clickXpath('//button[@title="Add Sound"]'); + await clickXpath('//button[@aria-label="Sound Library"]'); await clickText('A Bass'); // Closes the modal // Now add a sprite with only one sound. - await clickXpath('//button[@title="Add Sprite"]'); + await clickXpath('//button[@aria-label="Sprite Library"]'); await clickText('Abby'); // Doing this used to crash the editor. await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for error diff --git a/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap b/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap index 19abc2a16..4ecf19565 100644 --- a/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap +++ b/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap @@ -6,6 +6,8 @@ exports[`SpriteSelectorItemComponent matches snapshot when selected 1`] = ` onClick={[Function]} onContextMenu={[Function]} onMouseDown={[Function]} + onMouseEnter={undefined} + onMouseLeave={undefined} onMouseOut={[Function]} onMouseUp={[Function]} onTouchEnd={[Function]} diff --git a/test/unit/containers/sprite-selector-item.test.jsx b/test/unit/containers/sprite-selector-item.test.jsx index 2a371a1a0..a423fe816 100644 --- a/test/unit/containers/sprite-selector-item.test.jsx +++ b/test/unit/containers/sprite-selector-item.test.jsx @@ -12,6 +12,7 @@ describe('SpriteSelectorItem Container', () => { let costumeURL; let name; let onClick; + let dispatchSetHoveredSprite; let onDeleteButtonClick; let selected; let id; @@ -23,6 +24,7 @@ describe('SpriteSelectorItem Container', () => { { }; beforeEach(() => { - store = mockStore(); + store = mockStore({hoveredTarget: {receivedBlocks: false, sprite: null}}); className = 'ponies'; costumeURL = 'https://scratch.mit.edu/foo/bar/pony'; id = 1337; name = 'Pony sprite'; onClick = jest.fn(); onDeleteButtonClick = jest.fn(); + dispatchSetHoveredSprite = jest.fn(); selected = true; // Mock window.confirm() which is called when the close button is clicked. global.confirm = jest.fn(() => true); diff --git a/webpack.config.js b/webpack.config.js index c92f72ea7..389cf8abd 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -111,10 +111,6 @@ module.exports = { from: 'node_modules/scratch-blocks/media', to: 'static/blocks-media' }]), - new CopyWebpackPlugin([{ - from: 'node_modules/scratch-vm/dist/node/assets', - to: 'static/extension-assets' - }]), new CopyWebpackPlugin([{ from: 'extensions/**', to: 'static',