diff --git a/.gitignore b/.gitignore index 0b8a0db40e6..de7972e5eed 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # NPM /node_modules npm-* +/package-lock.json # Testing /.nyc_output diff --git a/.travis.yml b/.travis.yml index 9290080d2f3..e429117fd18 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,6 +10,7 @@ env: - NODE_ENV=production - SMOKE_URL=https://llk.github.io/scratch-gui/$TRAVIS_PULL_REQUEST_BRANCH - NPM_TAG=latest + - RELEASE_VERSION="0.1.0-prerelease.$(date +'%Y%m%d%H%M%S')" cache: directories: - node_modules @@ -22,8 +23,8 @@ script: before_deploy: - > if [ -z "$BEFORE_DEPLOY_RAN" ]; then - npm --no-git-tag-version version 0.1.0-prerelease.$(date +%Y%m%d%H%M%S) - if [ "$TRAVIS_BRANCH" == "develop" ]; then export NPM_TAG=develop; fi + npm --no-git-tag-version version $RELEASE_VERSION + if [ "$TRAVIS_BRANCH" == "master" ]; then export NPM_TAG=stable; fi git config --global user.email $(git log --pretty=format:"%ae" -n1) git config --global user.name $(git log --pretty=format:"%an" -n1) export BEFORE_DEPLOY_RAN=true @@ -40,6 +41,15 @@ deploy: email: $NPM_EMAIL api_key: $NPM_TOKEN tag: $NPM_TAG +- provider: script + on: + branch: + - master + - develop + - smoke + condition: $TRAVIS_EVENT_TYPE != cron + skip_cleanup: true + script: if npm info scratch-gui | grep -q $RELEASE_VERSION; then git tag $RELEASE_VERSION && git push https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git $RELEASE_VERSION; fi - provider: s3 on: branch: @@ -58,11 +68,6 @@ deploy: condition: $TRAVIS_EVENT_TYPE != cron skip_cleanup: true script: npm run deploy -- -x -e $TRAVIS_BRANCH -r https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git -- provider: script - on: - all_branches: true - condition: $TRAVIS_EVENT_TYPE != cron - script: npm run prune -- https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git - provider: script on: branch: develop diff --git a/README.md b/README.md index 1f49dca4f60..440a3a4b06a 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ npm install https://github.com/LLK/scratch-gui.git ``` If you want to edit/play yourself: ```bash -git clone git@github.com:LLK/scratch-gui.git +git clone https://github.com/LLK/scratch-gui.git cd scratch-gui npm install ``` diff --git a/package.json b/package.json index 38d6aa3014c..10601ea34a8 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "eslint": "^5.0.1", "eslint-config-scratch": "^5.0.0", "eslint-plugin-import": "^2.8.0", - "eslint-plugin-react": "^7.5.1", + "eslint-plugin-react": "7.11.1", "file-loader": "2.0.0", "get-float-time-domain-data": "0.1.0", "get-user-media-promise": "1.1.4", @@ -76,6 +76,8 @@ "lodash.throttle": "4.0.1", "minilog": "3.1.0", "mkdirp": "^0.5.1", + "omggif": "1.0.9", + "papaparse": "4.6.2", "postcss-import": "^12.0.0", "postcss-loader": "^3.0.0", "postcss-simple-vars": "^5.0.1", @@ -102,14 +104,14 @@ "redux-mock-store": "^1.2.3", "redux-throttle": "0.1.1", "rimraf": "^2.6.1", - "scratch-audio": "0.1.0-prerelease.20181023202904", - "scratch-blocks": "0.1.0-prerelease.1544625383", - "scratch-l10n": "3.1.20181210144244", - "scratch-paint": "0.2.0-prerelease.20181211153626", - "scratch-render": "0.1.0-prerelease.20181127194508", - "scratch-storage": "1.2.0", - "scratch-svg-renderer": "0.2.0-prerelease.20181126212715", - "scratch-vm": "0.2.0-prerelease.20181210154926", + "scratch-audio": "0.1.0-prerelease.20190114210212", + "scratch-blocks": "0.1.0-prerelease.1549990124", + "scratch-l10n": "3.1.20190218084652", + "scratch-paint": "0.2.0-prerelease.20190114205252", + "scratch-render": "0.1.0-prerelease.20190213183713", + "scratch-storage": "1.2.2", + "scratch-svg-renderer": "0.2.0-prerelease.20190125192231", + "scratch-vm": "0.2.0-prerelease.20190215190223", "selenium-webdriver": "3.6.0", "startaudiocontext": "1.2.1", "style-loader": "^0.23.0", diff --git a/src/components/action-menu/action-menu.jsx b/src/components/action-menu/action-menu.jsx index 36cf5d4449f..f9e50f3ff66 100644 --- a/src/components/action-menu/action-menu.jsx +++ b/src/components/action-menu/action-menu.jsx @@ -142,7 +142,7 @@ class ActionMenu extends React.Component {
{(moreButtons || []).map(({img, title, onClick: handleClick, - fileAccept, fileChange, fileInput}, keyId) => { + fileAccept, fileChange, fileInput, fileMultiple}, keyId) => { const isComingSoon = !handleClick; const hasFileInput = fileInput; const tooltipId = `${this.mainTooltipId}-${title}`; @@ -166,6 +166,7 @@ class ActionMenu extends React.Component { :last-child { + margin-left: 0; + margin-right: 0; +} diff --git a/src/components/alerts/alert.jsx b/src/components/alerts/alert.jsx index 4b6bdf493e3..db86bd4b8d6 100644 --- a/src/components/alerts/alert.jsx +++ b/src/components/alerts/alert.jsx @@ -34,14 +34,21 @@ const AlertComponent = ({ className={classNames(styles.alert, styles[level])} > {/* TODO: implement Rtl handling */} - {iconSpinner && ( - - )} - {iconURL && ( - + {(iconSpinner || iconURL) && ( +
+ {iconSpinner && ( + + )} + {iconURL && ( + + )} +
)}
{extensionName ? ( diff --git a/src/components/alerts/alerts.css b/src/components/alerts/alerts.css new file mode 100644 index 00000000000..3b7cf5221a8 --- /dev/null +++ b/src/components/alerts/alerts.css @@ -0,0 +1,4 @@ +.alerts-inner-container { + min-width: 200px; + max-width: 548px; +} diff --git a/src/components/alerts/alerts.jsx b/src/components/alerts/alerts.jsx index 21d6c01c45a..50acd7ce161 100644 --- a/src/components/alerts/alerts.jsx +++ b/src/components/alerts/alerts.jsx @@ -4,6 +4,8 @@ import PropTypes from 'prop-types'; import Box from '../box/box.jsx'; import Alert from '../../containers/alert.jsx'; +import styles from './alerts.css'; + const AlertsComponent = ({ alertsList, className, @@ -13,24 +15,26 @@ const AlertsComponent = ({ bounds="parent" className={className} > - {alertsList.map((a, index) => ( - - ))} + + {alertsList.map((a, index) => ( + + ))} + ); diff --git a/src/components/alerts/inline-message.jsx b/src/components/alerts/inline-message.jsx index 8a7330f7971..981e1984a1c 100644 --- a/src/components/alerts/inline-message.jsx +++ b/src/components/alerts/inline-message.jsx @@ -20,6 +20,7 @@ const InlineMessageComponent = ({ )} {content} diff --git a/src/components/asset-panel/selector.css b/src/components/asset-panel/selector.css index 535c38b0be6..611d901b1da 100644 --- a/src/components/asset-panel/selector.css +++ b/src/components/asset-panel/selector.css @@ -56,7 +56,7 @@ $fade-out-distance: 100px; .list-item { width: 5rem; - min-height: 5rem; + height: 5rem; margin: 0.5rem auto; } @@ -72,6 +72,6 @@ $fade-out-distance: 100px; .list-item.placeholder { - background: black; + background: white; filter: opacity(15%) brightness(0%); } diff --git a/src/components/audio-trimmer/audio-trimmer.css b/src/components/audio-trimmer/audio-trimmer.css index fe276c3819e..63af2a0cb2a 100644 --- a/src/components/audio-trimmer/audio-trimmer.css +++ b/src/components/audio-trimmer/audio-trimmer.css @@ -12,6 +12,9 @@ $hover-scale: 2; left: 0; width: 100%; height: 100%; + + /* Force the browser to paint separately to avoid composite cost with waveform */ + transform: translateZ(0); } .trim-background { @@ -50,8 +53,25 @@ $hover-scale: 2; border: 1px solid $red-tertiary; } +.playhead-container { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + overflow: hidden; +} + .playhead { - border: 1px solid $motion-primary; + /* + Even though playhead is just a line, it is 100% width (the width of the waveform) + so that we can use transform: translateX() using percentages. + */ + width: 100%; + border-left: 1px solid $motion-primary; + border-top: none; + border-bottom: none; + border-right: none; } .start-trim-line { diff --git a/src/components/audio-trimmer/audio-trimmer.jsx b/src/components/audio-trimmer/audio-trimmer.jsx index d1b21c92664..87ca75a9803 100644 --- a/src/components/audio-trimmer/audio-trimmer.jsx +++ b/src/components/audio-trimmer/audio-trimmer.jsx @@ -32,12 +32,14 @@ const AudioTrimmer = props => ( )} {props.playhead ? ( - +
+
+
) : null} {props.trimEnd === null ? null : ( diff --git a/src/components/backpack/backpack.css b/src/components/backpack/backpack.css index 6eddfa905c4..25bc42d45b4 100644 --- a/src/components/backpack/backpack.css +++ b/src/components/backpack/backpack.css @@ -57,7 +57,7 @@ width: 100%; height: 100%; opacity: 0.75; - background-color: #8cbcff; + background-color: $drop-highlight; transition: all 0.25s ease; } @@ -69,8 +69,18 @@ } .backpack-item { - min-width: 4rem; + width: 4rem; + height: 4.5rem; margin: 0 0.25rem; + flex-shrink: 0; + + /* Need to hide overflow because of background setting below */ + overflow: hidden; +} + +.backpack-item > div { + /* Need to set the background to get blend-mode below to work */ + background: $ui-primary; } .backpack-item img { diff --git a/src/components/blocks/blocks.css b/src/components/blocks/blocks.css index e0b07ce26cc..583f587f797 100644 --- a/src/components/blocks/blocks.css +++ b/src/components/blocks/blocks.css @@ -1,5 +1,6 @@ @import "../../css/units.css"; @import "../../css/colors.css"; +@import "../../css/z-index.css"; .blocks { height: 100%; @@ -13,7 +14,7 @@ width: 100%; height: 100%; opacity: 0.75; - background-color: #8cbcff; + background-color: $drop-highlight; transition: all 0.25s ease; } @@ -80,6 +81,7 @@ This does not prevent user interaction on the blocks themselves. */ pointer-events: none; + z-index: $z-index-drag-layer; /* make blocks match gui drag layer */ } /* diff --git a/src/components/browser-modal/browser-modal.css b/src/components/browser-modal/browser-modal.css index a7f86ac4efe..2f4145bf665 100644 --- a/src/components/browser-modal/browser-modal.css +++ b/src/components/browser-modal/browser-modal.css @@ -27,17 +27,23 @@ } .illustration { + display: flex; + align-items: center; + justify-content: center; width: 100%; - height: 208px; - background-color: $motion-primary; - background-image: url('./unsupported.png'); - background-size: cover; + height: 100px; + background-color: $control-primary; } [dir="rtl"] .illustration { transform: scaleX(-1); } +.illustration img { + height: 80%; + width: auto; +} + .body { background: $ui-white; padding: 1.5rem 2.25rem; diff --git a/src/components/browser-modal/browser-modal.jsx b/src/components/browser-modal/browser-modal.jsx index 9afac0e3891..0419e9122d1 100644 --- a/src/components/browser-modal/browser-modal.jsx +++ b/src/components/browser-modal/browser-modal.jsx @@ -5,85 +5,107 @@ import Box from '../box/box.jsx'; import {defineMessages, injectIntl, intlShape, FormattedMessage} from 'react-intl'; import styles from './browser-modal.css'; +import unhappyBrowser from './unsupported-browser.svg'; const messages = defineMessages({ label: { id: 'gui.unsupportedBrowser.label', defaultMessage: 'Browser is not supported', description: '' + }, + error: { + id: 'gui.unsupportedBrowser.errorLabel', + defaultMessage: 'An Error Occurred', + description: 'Heading shown when there is an unhandled exception in an unsupported browser' } }); -const BrowserModal = ({intl, ...props}) => ( - -
- +const BrowserModal = ({intl, ...props}) => { + const label = props.error ? messages.error : messages.label; + return ( + +
+ + + + + +

+ +

+

+ { /* eslint-disable max-len */ } + { + props.error ? : + } + { /* eslint-enable max-len */ } +

- -

- -

-

- { /* eslint-disable max-len */ } - - { /* eslint-enable max-len */ } -

+ + - - - +
-
- - - - ) - }} - /> -
- -
-
-); +
+ + ); +}; BrowserModal.propTypes = { + error: PropTypes.bool, intl: intlShape.isRequired, isRtl: PropTypes.bool, onBack: PropTypes.func.isRequired }; +BrowserModal.defaultProps = { + error: false +}; + const WrappedBrowserModal = injectIntl(BrowserModal); WrappedBrowserModal.setAppElement = ReactModal.setAppElement; diff --git a/src/components/browser-modal/unsupported-browser.svg b/src/components/browser-modal/unsupported-browser.svg new file mode 100644 index 00000000000..7749f7539b3 --- /dev/null +++ b/src/components/browser-modal/unsupported-browser.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/cards/cards.jsx b/src/components/cards/cards.jsx index 129d2ca5714..f137c91a003 100644 --- a/src/components/cards/cards.jsx +++ b/src/components/cards/cards.jsx @@ -234,7 +234,11 @@ const Cards = props => { if (x === 0 && y === 0) { // initialize positions x = isRtl ? -292 : 292; - y = 365; + // The tallest cards are about 385px high, and the default position is pinned + // to near the bottom of the blocks palette to allow room to work above. + const tallCardHeight = 385; + const bottomMargin = 60; // To avoid overlapping the backpack region + y = window.innerHeight - tallCardHeight - bottomMargin; } const steps = content[activeDeckId].steps; diff --git a/src/components/context-menu/context-menu.css b/src/components/context-menu/context-menu.css index 5a02ce797d9..84e2699f0a1 100644 --- a/src/components/context-menu/context-menu.css +++ b/src/components/context-menu/context-menu.css @@ -28,3 +28,11 @@ background: $motion-primary; color: white; } + +.menu-item-bordered { + border-top: 1px solid $ui-black-transparent; +} + +.menu-item-bordered:hover { + background: $error-primary; +} diff --git a/src/components/context-menu/context-menu.jsx b/src/components/context-menu/context-menu.jsx index 7d33fe641ef..677f8944190 100644 --- a/src/components/context-menu/context-menu.jsx +++ b/src/components/context-menu/context-menu.jsx @@ -1,5 +1,6 @@ import React from 'react'; import {ContextMenu, MenuItem} from 'react-contextmenu'; +import classNames from 'classnames'; import styles from './context-menu.css'; @@ -17,7 +18,16 @@ const StyledMenuItem = props => ( /> ); +const BorderedMenuItem = props => ( + +); + + export { + BorderedMenuItem, StyledContextMenu as ContextMenu, StyledMenuItem as MenuItem }; diff --git a/src/components/crash-message/crash-message.jsx b/src/components/crash-message/crash-message.jsx index 4fe8cc0ec3e..c3732ddc0cb 100644 --- a/src/components/crash-message/crash-message.jsx +++ b/src/components/crash-message/crash-message.jsx @@ -29,6 +29,18 @@ const CrashMessage = props => ( id="gui.crashMessage.description" />

+ {props.eventId && ( +

+ +

+ )} -
- {props.hasValidationError ? - -

- -

-
: null - } - - - - - - - - ) - }} - /> - - -
- -); - -ImportModal.propTypes = { - errorMessage: PropTypes.string.isRequired, - hasValidationError: PropTypes.bool.isRequired, - inputValue: PropTypes.string.isRequired, - intl: intlShape.isRequired, - isRtl: PropTypes.bool, - 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/library-item/library-item.css b/src/components/library-item/library-item.css index 55edcc2e33e..692e1b61278 100644 --- a/src/components/library-item/library-item.css +++ b/src/components/library-item/library-item.css @@ -8,6 +8,7 @@ justify-content: flex-start; flex-basis: 160px; height: 160px; + max-width: 160px; margin: $space; padding: 1rem 1rem 0 1rem; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; diff --git a/src/components/library-item/library-item.jsx b/src/components/library-item/library-item.jsx index 03a9d6b7886..3880a4cf613 100644 --- a/src/components/library-item/library-item.jsx +++ b/src/components/library-item/library-item.jsx @@ -1,4 +1,3 @@ -import bindAll from 'lodash.bindall'; import {FormattedMessage} from 'react-intl'; import PropTypes from 'prop-types'; import React from 'react'; @@ -10,42 +9,8 @@ import classNames from 'classnames'; import bluetoothIconURL from './bluetooth.svg'; import internetConnectionIconURL from './internet-connection.svg'; -class LibraryItem extends React.PureComponent { - constructor (props) { - super(props); - bindAll(this, [ - 'handleBlur', - 'handleClick', - 'handleFocus', - 'handleKeyPress', - 'handleMouseEnter', - 'handleMouseLeave' - ]); - } - handleBlur () { - this.props.onBlur(this.props.id); - } - handleFocus () { - this.props.onFocus(this.props.id); - } - handleClick (e) { - if (!this.props.disabled) { - this.props.onSelect(this.props.id); - } - e.preventDefault(); - } - handleKeyPress (e) { - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault(); - this.props.onSelect(this.props.id); - } - } - handleMouseEnter () { - this.props.onMouseEnter(this.props.id); - } - handleMouseLeave () { - this.props.onMouseLeave(this.props.id); - } +/* eslint-disable react/prefer-stateless-function */ +class LibraryItemComponent extends React.PureComponent { render () { return this.props.featured ? (
{this.props.disabled ? ( @@ -146,12 +111,12 @@ class LibraryItem extends React.PureComponent { )} role="button" tabIndex="0" - onBlur={this.handleBlur} - onClick={this.handleClick} - onFocus={this.handleFocus} - onKeyPress={this.handleKeyPress} - onMouseEnter={this.handleMouseEnter} - onMouseLeave={this.handleMouseLeave} + onBlur={this.props.onBlur} + onClick={this.props.onClick} + onFocus={this.props.onFocus} + onKeyPress={this.props.onKeyPress} + onMouseEnter={this.props.onMouseEnter} + onMouseLeave={this.props.onMouseLeave} > {/* Layers of wrapping is to prevent layout thrashing on animation */} @@ -167,8 +132,10 @@ class LibraryItem extends React.PureComponent { ); } } +/* eslint-enable react/prefer-stateless-function */ + -LibraryItem.propTypes = { +LibraryItemComponent.propTypes = { bluetoothRequired: PropTypes.bool, collaborator: PropTypes.string, description: PropTypes.oneOfType([ @@ -180,22 +147,22 @@ LibraryItem.propTypes = { featured: PropTypes.bool, hidden: PropTypes.bool, iconURL: PropTypes.string, - id: PropTypes.number.isRequired, insetIconURL: PropTypes.string, internetConnectionRequired: PropTypes.bool, name: PropTypes.oneOfType([ PropTypes.string, PropTypes.node ]), - onBlur: PropTypes.func, - onFocus: PropTypes.func, + onBlur: PropTypes.func.isRequired, + onClick: PropTypes.func.isRequired, + onFocus: PropTypes.func.isRequired, + onKeyPress: PropTypes.func.isRequired, onMouseEnter: PropTypes.func.isRequired, - onMouseLeave: PropTypes.func.isRequired, - onSelect: PropTypes.func.isRequired + onMouseLeave: PropTypes.func.isRequired }; -LibraryItem.defaultProps = { +LibraryItemComponent.defaultProps = { disabled: false }; -export default LibraryItem; +export default LibraryItemComponent; diff --git a/src/components/library/library.jsx b/src/components/library/library.jsx index 8ecb76181a2..8073b0f4928 100644 --- a/src/components/library/library.jsx +++ b/src/components/library/library.jsx @@ -4,7 +4,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import {defineMessages, injectIntl, intlShape} from 'react-intl'; -import LibraryItem from '../library-item/library-item.jsx'; +import LibraryItem from '../../containers/library-item.jsx'; import Modal from '../../containers/modal.jsx'; import Divider from '../divider/divider.jsx'; import Filter from '../filter/filter.jsx'; @@ -33,11 +33,9 @@ class LibraryComponent extends React.Component { constructor (props) { super(props); bindAll(this, [ - 'handleBlur', 'handleClose', 'handleFilterChange', 'handleFilterClear', - 'handleFocus', 'handleMouseEnter', 'handleMouseLeave', 'handleSelect', @@ -56,12 +54,6 @@ class LibraryComponent extends React.Component { this.scrollToTop(); } } - handleBlur (id) { - this.handleMouseLeave(id); - } - handleFocus (id) { - this.handleMouseEnter(id); - } handleSelect (id) { this.handleClose(); this.props.onItemSelected(this.getFilteredData()[id]); @@ -172,33 +164,28 @@ class LibraryComponent extends React.Component { })} ref={this.setFilteredDataRef} > - {this.getFilteredData().map((dataItem, index) => { - const scratchURL = dataItem.md5 ? - `https://cdn.assets.scratch.mit.edu/internalapi/asset/${dataItem.md5}/get/` : - dataItem.rawURL; - return ( -
); diff --git a/src/components/loader/loader.css b/src/components/loader/loader.css index 1b6ab40ce65..b57aa4c3aa2 100644 --- a/src/components/loader/loader.css +++ b/src/components/loader/loader.css @@ -2,7 +2,7 @@ @import "../../css/z-index.css"; .background { - position: fixed; + position: absolute; top: 0; left: 0; width: 100%; @@ -17,6 +17,13 @@ color: white; } +.fullscreen { + /* Break out of the layout using position: fixed to cover the whole screen */ + position: fixed; + /* Use the fullscreen stage z-index to allow covering full-screen mode */ + z-index: $z-index-stage-wrapper-overlay; +} + .block-animation { width: 125px; height: 150px; diff --git a/src/components/loader/loader.jsx b/src/components/loader/loader.jsx index e06b8bb35f6..c51621fc41a 100644 --- a/src/components/loader/loader.jsx +++ b/src/components/loader/loader.jsx @@ -1,5 +1,6 @@ import React from 'react'; import {FormattedMessage} from 'react-intl'; +import classNames from 'classnames'; import styles from './loader.css'; import PropTypes from 'prop-types'; @@ -119,15 +120,13 @@ class LoaderComponent extends React.Component { constructor (props) { super(props); this.state = { - messageNumber: 0 + messageNumber: this.chooseRandomMessage() }; } componentDidMount () { - this.chooseRandomMessage(); - // Start an interval to choose a new message every 5 seconds this.intervalId = setInterval(() => { - this.chooseRandomMessage(); + this.setState({messageNumber: this.chooseRandomMessage()}); }, 5000); } componentWillUnmount () { @@ -144,11 +143,15 @@ class LoaderComponent extends React.Component { break; } } - this.setState({messageNumber}); + return messageNumber; } render () { return ( -
+
- - Scratch - + Scratch
- - {this.props.canSave ? ( - - {saveNowMessage} - - ) : []} - {this.props.canCreateCopy ? ( - - {createCopyMessage} - - ) : []} - {this.props.canRemix ? ( - - {remixMessage} - - ) : []} - + {(this.props.canSave || this.props.canCreateCopy || this.props.canRemix) && ( + + {this.props.canSave ? ( + + {saveNowMessage} + + ) : []} + {this.props.canCreateCopy ? ( + + {createCopyMessage} + + ) : []} + {this.props.canRemix ? ( + + {remixMessage} + + ) : []} + + )} {(className, renderFileInput, loadProject) => ( @@ -715,6 +735,7 @@ MenuBar.propTypes = { onClickFile: PropTypes.func, onClickLanguage: PropTypes.func, onClickLogin: PropTypes.func, + onClickLogo: PropTypes.func, onClickNew: PropTypes.func, onClickRemix: PropTypes.func, onClickSave: PropTypes.func, @@ -731,6 +752,7 @@ MenuBar.propTypes = { onShare: PropTypes.func, onToggleLoginOpen: PropTypes.func, onUpdateProjectTitle: PropTypes.func, + projectChanged: PropTypes.bool, projectTitle: PropTypes.string, renderLogin: PropTypes.func, sessionExists: PropTypes.bool, @@ -754,6 +776,7 @@ const mapStateToProps = state => { isShowingProject: getIsShowingProject(loadingState), languageMenuOpen: languageMenuOpen(state), loginMenuOpen: loginMenuOpen(state), + projectChanged: state.scratchGui.projectChanged, projectTitle: state.scratchGui.projectTitle, sessionExists: state.session && typeof state.session.session !== 'undefined', username: user ? user.username : null diff --git a/src/components/meter/meter.css b/src/components/meter/meter.css index c16e920cd73..953f3641b75 100644 --- a/src/components/meter/meter.css +++ b/src/components/meter/meter.css @@ -1,3 +1,5 @@ +@import "../../css/colors.css"; + .green { fill: rgb(171, 220, 170); stroke: rgb(174, 211, 168); @@ -12,3 +14,19 @@ fill: rgb(251, 194, 142); stroke: rgb(235, 189, 142); } + +.mask-container { + position: relative; +} + +.mask { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + transform-origin: top; + will-change: transform; + background: $ui-primary; + opacity: 0.75; +} diff --git a/src/components/meter/meter.jsx b/src/components/meter/meter.jsx index 2d16ce681da..4f071191689 100644 --- a/src/components/meter/meter.jsx +++ b/src/components/meter/meter.jsx @@ -20,36 +20,40 @@ const Meter = props => { const barHeight = (height - (barSpacing * (nBars + 1))) / nBars; const nBarsToMask = nBars - Math.floor(level * nBars); + const scale = ((nBarsToMask * (barHeight + barSpacing)) + (barSpacing / 2)) / height; return ( - - {Array(nBars).fill(0) - .map((value, index) => ( - - ))} - + {Array(nBars).fill(0) + .map((value, index) => ( + + ))} + +
- +
); }; diff --git a/src/components/monitor-list/monitor-list.jsx b/src/components/monitor-list/monitor-list.jsx index f7b02080fdf..e4da715b0b4 100644 --- a/src/components/monitor-list/monitor-list.jsx +++ b/src/components/monitor-list/monitor-list.jsx @@ -27,6 +27,7 @@ const MonitorList = props => ( draggable={props.draggable} height={monitorData.height} id={monitorData.id} + isDiscrete={monitorData.isDiscrete} key={monitorData.id} max={monitorData.sliderMax} min={monitorData.sliderMin} diff --git a/src/components/monitor/monitor.jsx b/src/components/monitor/monitor.jsx index 7e6156ff005..22134327593 100644 --- a/src/components/monitor/monitor.jsx +++ b/src/components/monitor/monitor.jsx @@ -54,35 +54,52 @@ const MonitorComponent = props => ( })} - {props.mode === 'list' ? null : ReactDOM.createPortal(( + {ReactDOM.createPortal(( // Use a portal to render the context menu outside the flow to avoid // positioning conflicts between the monitors `transform: scale` and // the context menus `position: fixed`. For more details, see // http://meyerweb.com/eric/thoughts/2011/09/12/un-fixing-fixed-elements-with-css-transforms/ - - - - - - - {props.onSetModeToSlider ? ( + {props.onSetModeToDefault && + + + } + {props.onSetModeToLarge && + + + } + {props.onSetModeToSlider && - - ) : null} + } + {props.onImport && + + + } + {props.onExport && + + + } ), document.body)} @@ -100,9 +117,11 @@ MonitorComponent.propTypes = { label: PropTypes.string.isRequired, mode: PropTypes.oneOf(monitorModes), onDragEnd: PropTypes.func.isRequired, + onExport: PropTypes.func, + onImport: PropTypes.func, onNextMode: PropTypes.func.isRequired, - onSetModeToDefault: PropTypes.func.isRequired, - onSetModeToLarge: PropTypes.func.isRequired, + onSetModeToDefault: PropTypes.func, + onSetModeToLarge: PropTypes.func, onSetModeToSlider: PropTypes.func }; diff --git a/src/components/monitor/slider-monitor.jsx b/src/components/monitor/slider-monitor.jsx index b9cebfa140a..0271b140a03 100644 --- a/src/components/monitor/slider-monitor.jsx +++ b/src/components/monitor/slider-monitor.jsx @@ -4,7 +4,7 @@ import classNames from 'classnames'; import styles from './monitor.css'; -const SliderMonitor = ({categoryColor, label, min, max, value, onSliderUpdate}) => ( +const SliderMonitor = ({categoryColor, isDiscrete, label, min, max, value, onSliderUpdate}) => (
@@ -22,6 +22,7 @@ const SliderMonitor = ({categoryColor, label, min, max, value, onSliderUpdate}) className={classNames(styles.slider, 'no-drag')} // Class used on parent Draggable to prevent drags max={max} min={min} + step={isDiscrete ? 1 : 0.01} type="range" value={value} onChange={onSliderUpdate} @@ -33,6 +34,7 @@ const SliderMonitor = ({categoryColor, label, min, max, value, onSliderUpdate}) SliderMonitor.propTypes = { categoryColor: PropTypes.string.isRequired, + isDiscrete: PropTypes.bool, label: PropTypes.string.isRequired, max: PropTypes.number, min: PropTypes.number, @@ -44,6 +46,7 @@ SliderMonitor.propTypes = { }; SliderMonitor.defaultProps = { + isDiscrete: true, min: 0, max: 100 }; diff --git a/src/components/preview-modal/happy-cat.png b/src/components/preview-modal/happy-cat.png deleted file mode 100644 index 764761eea93..00000000000 Binary files a/src/components/preview-modal/happy-cat.png and /dev/null differ diff --git a/src/components/preview-modal/preview-modal.jsx b/src/components/preview-modal/preview-modal.jsx deleted file mode 100644 index babfc0a275c..00000000000 --- a/src/components/preview-modal/preview-modal.jsx +++ /dev/null @@ -1,144 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import ReactModal from 'react-modal'; -import Box from '../box/box.jsx'; -import {defineMessages, injectIntl, intlShape, FormattedMessage} from 'react-intl'; - -import styles from './preview-modal.css'; -import catIcon from './happy-cat.png'; - -const messages = defineMessages({ - label: { - id: 'gui.previewInfo.label', - defaultMessage: 'Try Scratch 3.0', - description: 'Scratch 3.0 modal label - for accessibility' - }, - previewWelcome: { - defaultMessage: 'Welcome to the Scratch 3.0 Beta', - description: 'Header for Preview Info Modal', - id: 'gui.previewInfo.welcome' - }, - notNowTooltip: { - defaultMessage: 'Not Now', - description: 'Tooltip for Not Now button', - id: 'gui.previewModal.notnowtooltip' - }, - tryItTooltip: { - defaultMessage: 'Try It', - description: 'Tooltip for Try It button', - id: 'gui.previewModal.tryittooltip' - }, - viewProjectTooltip: { - defaultMessage: 'View 2.0 Project', - description: 'Tooltip for View 2.0 Project button', - id: 'gui.previewModal.viewprojecttooltip' - } -}); - -const PreviewModal = ({intl, ...props}) => ( - -
- - - -

- -

-

- { /* eslint-disable max-len */ } - - { /* eslint-enable max-len */ } -

- - - - - - - - - - - ) - }} - /> - -
-
-
-); - -PreviewModal.propTypes = { - intl: intlShape.isRequired, - isRtl: PropTypes.bool, - onCancel: PropTypes.func.isRequired, - onTryIt: PropTypes.func.isRequired, - onViewProject: PropTypes.func.isRequired -}; - -export default injectIntl(PreviewModal); diff --git a/src/components/preview-modal/welcome.png b/src/components/preview-modal/welcome.png deleted file mode 100644 index 1172df932b4..00000000000 Binary files a/src/components/preview-modal/welcome.png and /dev/null differ diff --git a/src/components/prompt/prompt.jsx b/src/components/prompt/prompt.jsx index 1c756550901..d60af1f3446 100644 --- a/src/components/prompt/prompt.jsx +++ b/src/components/prompt/prompt.jsx @@ -47,9 +47,10 @@ const PromptComponent = props => ( @@ -137,16 +138,17 @@ const PromptComponent = props => ( PromptComponent.propTypes = { canAddCloudVariable: PropTypes.bool.isRequired, cloudSelected: PropTypes.bool.isRequired, + defaultValue: PropTypes.string, globalSelected: PropTypes.bool.isRequired, isStage: PropTypes.bool.isRequired, label: PropTypes.string.isRequired, onCancel: PropTypes.func.isRequired, onChange: PropTypes.func.isRequired, onCloudVarOptionChange: PropTypes.func, + onFocus: PropTypes.func.isRequired, onKeyPress: PropTypes.func.isRequired, onOk: PropTypes.func.isRequired, onScopeOptionSelection: PropTypes.func.isRequired, - placeholder: PropTypes.string, showCloudOption: PropTypes.bool.isRequired, showVariableOptions: PropTypes.bool.isRequired, title: PropTypes.string.isRequired diff --git a/src/components/sound-editor/sound-editor.css b/src/components/sound-editor/sound-editor.css index 4a0e03655da..279875fc8dc 100644 --- a/src/components/sound-editor/sound-editor.css +++ b/src/components/sound-editor/sound-editor.css @@ -79,6 +79,7 @@ $border-radius: 0.25rem; font-size: 0.85rem; transition: 0.2s; user-select: none; + margin: 0px; } .button > img { diff --git a/src/components/spinner/spinner.css b/src/components/spinner/spinner.css index a20e79b1371..3abc8ffbbea 100644 --- a/src/components/spinner/spinner.css +++ b/src/components/spinner/spinner.css @@ -1,19 +1,20 @@ @import "../../css/colors.css"; .spinner { - width: 1rem; - height: 1rem; + width: 1.25rem; + height: 1.25rem; display: inline-block; position: relative; border-radius: 50%; border-width: .1875rem; border-style: solid; border-color: $ui-white-transparent; + box-sizing: content-box; } .spinner::before, .spinner::after { - width: 1rem; - height: 1rem; + width: 1.25rem; + height: 1.25rem; content: ''; border-radius: 50%; display: block; @@ -48,17 +49,25 @@ } } -.spinner.orange { +.spinner.success { + border-color: $extensions-transparent; +} + +.spinner.success::after { + border-top-color: $extensions-primary; +} + +.spinner.warn { border-color: $error-transparent; } -.spinner.orange::after { +.spinner.warn::after { border-top-color: $error-primary; } -.spinner.white { +.spinner.info { border-color: $ui-white-transparent; } -.spinner.white::after { +.spinner.info::after { border-top-color: $ui-white; } diff --git a/src/components/spinner/spinner.jsx b/src/components/spinner/spinner.jsx index bb82fbf51c6..f25e3ebb187 100644 --- a/src/components/spinner/spinner.jsx +++ b/src/components/spinner/spinner.jsx @@ -7,6 +7,7 @@ import styles from './spinner.css'; const SpinnerComponent = function (props) { const { className, + level, small } = props; return ( @@ -14,6 +15,7 @@ const SpinnerComponent = function (props) { className={classNames( className, styles.spinner, + styles[level], {[styles.small]: small} )} /> @@ -21,9 +23,11 @@ const SpinnerComponent = function (props) { }; SpinnerComponent.propTypes = { className: PropTypes.string, + level: PropTypes.string, small: PropTypes.bool }; SpinnerComponent.defaultProps = { - className: '' + className: '', + level: 'info' }; export default SpinnerComponent; diff --git a/src/components/sprite-info/sprite-info.jsx b/src/components/sprite-info/sprite-info.jsx index e9090bfe387..09c94d0162a 100644 --- a/src/components/sprite-info/sprite-info.jsx +++ b/src/components/sprite-info/sprite-info.jsx @@ -34,14 +34,15 @@ class SpriteInfo extends React.Component { shouldComponentUpdate (nextProps) { return ( this.props.rotationStyle !== nextProps.rotationStyle || - this.props.direction !== nextProps.direction || this.props.disabled !== nextProps.disabled || this.props.name !== nextProps.name || - this.props.size !== nextProps.size || this.props.stageSize !== nextProps.stageSize || this.props.visible !== nextProps.visible || - this.props.x !== nextProps.x || - this.props.y !== nextProps.y + // Only update these if rounded value has changed + Math.round(this.props.direction) !== Math.round(nextProps.direction) || + Math.round(this.props.size) !== Math.round(nextProps.size) || + Math.round(this.props.x) !== Math.round(nextProps.x) || + Math.round(this.props.y) !== Math.round(nextProps.y) ); } render () { @@ -110,7 +111,7 @@ class SpriteInfo extends React.Component { placeholder="x" tabIndex="0" type="text" - value={this.props.disabled ? '' : this.props.x} + value={this.props.disabled ? '' : Math.round(this.props.x)} onSubmit={this.props.onChangeX} /> @@ -137,7 +138,7 @@ class SpriteInfo extends React.Component { placeholder="y" tabIndex="0" type="text" - value={this.props.disabled ? '' : this.props.y} + value={this.props.disabled ? '' : Math.round(this.props.y)} onSubmit={this.props.onChangeY} /> @@ -237,14 +238,14 @@ class SpriteInfo extends React.Component { label={sizeLabel} tabIndex="0" type="text" - value={this.props.disabled ? '' : this.props.size} + value={this.props.disabled ? '' : Math.round(this.props.size)} onSubmit={this.props.onChangeSize} />
( disable={props.dragging} id={`${props.name}-${contextMenuId}`} > - {(props.selected && props.onDeleteButtonClick) ? ( - - ) : null } {typeof props.number === 'undefined' ? null : (
{props.number}
)} {props.costumeURL ? ( - +
+
+ +
+
) : null}
{props.name}
@@ -49,6 +46,13 @@ const SpriteSelectorItem = props => (
{props.details}
) : null}
+ {(props.selected && props.onDeleteButtonClick) ? ( + + ) : null } {props.onDuplicateButtonClick || props.onDeleteButtonClick || props.onExportButtonClick ? ( {props.onDuplicateButtonClick ? ( @@ -60,15 +64,6 @@ const SpriteSelectorItem = props => ( /> ) : null} - {props.onDeleteButtonClick ? ( - - - - ) : null } {props.onExportButtonClick ? ( ( /> ) : null } + {props.onDeleteButtonClick ? ( + + + + ) : null } ) : null} diff --git a/src/components/sprite-selector/sprite-list.jsx b/src/components/sprite-selector/sprite-list.jsx index 17ba2dc2153..e89898e47ce 100644 --- a/src/components/sprite-selector/sprite-list.jsx +++ b/src/components/sprite-selector/sprite-list.jsx @@ -8,9 +8,12 @@ import Box from '../box/box.jsx'; import SpriteSelectorItem from '../../containers/sprite-selector-item.jsx'; import SortableHOC from '../../lib/sortable-hoc.jsx'; import SortableAsset from '../asset-panel/sortable-asset.jsx'; +import ThrottledPropertyHOC from '../../lib/throttled-property-hoc.jsx'; import styles from './sprite-selector.css'; +const ThrottledSpriteSelectorItem = ThrottledPropertyHOC('asset', 500)(SpriteSelectorItem); + const SpriteList = function (props) { const { containerRef, @@ -34,62 +37,68 @@ const SpriteList = function (props) { return ( - {items.map((sprite, index) => { + + {items.map((sprite, index) => { - // If the sprite has just received a block drop, used for green highlight - const receivedBlocks = ( - hoveredTarget.sprite === sprite.id && + // If the sprite has just received a block drop, used for green highlight + const receivedBlocks = ( + hoveredTarget.sprite === sprite.id && sprite.id !== editingTarget && hoveredTarget.receivedBlocks - ); + ); - // If the sprite is indicating it can receive block dropping, used for blue highlight - let isRaised = !receivedBlocks && raised && sprite.id !== editingTarget; + // If the sprite is indicating it can receive block dropping, used for blue highlight + let isRaised = !receivedBlocks && raised && sprite.id !== editingTarget; - // A sprite is also raised if a costume or sound is being dragged. - // Note the absence of the self-sharing check: a sprite can share assets with itself. - // This is a quirk of 2.0, but seems worth leaving possible, it - // allows quick (albeit unusual) duplication of assets. - isRaised = isRaised || [ - DragConstants.COSTUME, - DragConstants.SOUND, - DragConstants.BACKPACK_COSTUME, - DragConstants.BACKPACK_SOUND, - DragConstants.BACKPACK_CODE].includes(draggingType); + // A sprite is also raised if a costume or sound is being dragged. + // Note the absence of the self-sharing check: a sprite can share assets with itself. + // This is a quirk of 2.0, but seems worth leaving possible, it + // allows quick (albeit unusual) duplication of assets. + isRaised = isRaised || [ + DragConstants.COSTUME, + DragConstants.SOUND, + DragConstants.BACKPACK_COSTUME, + DragConstants.BACKPACK_SOUND, + DragConstants.BACKPACK_CODE].includes(draggingType); - return ( - - - - ); - })} + return ( + + + + ); + })} + ); }; diff --git a/src/components/sprite-selector/sprite-selector.css b/src/components/sprite-selector/sprite-selector.css index da89efab043..ffade4750e3 100644 --- a/src/components/sprite-selector/sprite-selector.css +++ b/src/components/sprite-selector/sprite-selector.css @@ -31,6 +31,7 @@ */ box-sizing: border-box; width: calc((100% / $sprites-per-row ) - $space); + max-width: 6rem; min-width: 4rem; min-height: 4rem; /* @todo: calc height same as width */ margin: calc($space / 2); @@ -54,6 +55,10 @@ overflow-y: auto; } +.scroll-wrapper-dragging { + background-color: $drop-highlight; +} + .items-wrapper { display: flex; flex-wrap: wrap; @@ -79,12 +84,12 @@ } .raised { - background-color: #8cbcff; + background-color: $drop-highlight; transition: all 0.25s ease; } .raised:hover { - background-color: #8cbcff; + background-color: $drop-highlight; transform: scale(1.05); } @@ -93,7 +98,7 @@ animation-duration: 500ms; animation-iteration-count: 1; animation-timing-function: ease-in-out; - background-color: #8cbcff; + background-color: $drop-highlight; } @keyframes wiggle { diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx index 981e44b0fe3..08e18844eff 100644 --- a/src/components/sprite-selector/sprite-selector.jsx +++ b/src/components/sprite-selector/sprite-selector.jsx @@ -100,20 +100,18 @@ const SpriteSelectorComponent = function (props) { onChangeY={onChangeSpriteY} /> - - sprites[id])} - raised={raised} - selectedId={selectedId} - onDeleteSprite={onDeleteSprite} - onDrop={onDrop} - onDuplicateSprite={onDuplicateSprite} - onExportSprite={onExportSprite} - onSelectSprite={onSelectSprite} - /> - + sprites[id])} + raised={raised} + selectedId={selectedId} + onDeleteSprite={onDeleteSprite} + onDrop={onDrop} + onDuplicateSprite={onDuplicateSprite} + onExportSprite={onExportSprite} + onSelectSprite={onSelectSprite} + /> { title: intl.formatMessage(messages.addBackdropFromFile), img: fileUploadIcon, onClick: onBackdropFileUploadClick, - fileAccept: '.svg, .png, .jpg, .jpeg', // Bitmap coming soon + fileAccept: '.svg, .png, .jpg, .jpeg, .gif', fileChange: onBackdropFileUpload, - fileInput: fileInputRef + fileInput: fileInputRef, + fileMultiple: true }, { title: intl.formatMessage(messages.addBackdropFromSurprise), img: surpriseIcon, diff --git a/src/components/stage-wrapper/stage-wrapper.jsx b/src/components/stage-wrapper/stage-wrapper.jsx index 021f6742ba6..77e9f55953b 100644 --- a/src/components/stage-wrapper/stage-wrapper.jsx +++ b/src/components/stage-wrapper/stage-wrapper.jsx @@ -6,13 +6,16 @@ import Box from '../box/box.jsx'; import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants.js'; import StageHeader from '../../containers/stage-header.jsx'; import Stage from '../../containers/stage.jsx'; +import Loader from '../loader/loader.jsx'; import styles from './stage-wrapper.css'; const StageWrapperComponent = function (props) { const { + isFullScreen, isRtl, isRendererSupported, + loading, stageSize, vm } = props; @@ -38,13 +41,18 @@ const StageWrapperComponent = function (props) { null } + {loading ? ( + + ) : null} ); }; StageWrapperComponent.propTypes = { + isFullScreen: PropTypes.bool, isRendererSupported: PropTypes.bool.isRequired, - isRtl: PropTypes.bool, + isRtl: PropTypes.bool.isRequired, + loading: PropTypes.bool, stageSize: PropTypes.oneOf(Object.keys(STAGE_DISPLAY_SIZES)).isRequired, vm: PropTypes.instanceOf(VM).isRequired }; diff --git a/src/components/stage/stage.css b/src/components/stage/stage.css index 7e0a59c2e78..19496c09bcf 100644 --- a/src/components/stage/stage.css +++ b/src/components/stage/stage.css @@ -9,9 +9,9 @@ */ display: block; - /* Attach border radius directly to canvas to prevent needing overflow:hidden; */ border-radius: $space; border: $stage-standard-border-width solid $ui-black-transparent; + overflow: hidden; /* @todo: This is for overriding the value being set somewhere. Where is it being set? */ background-color: transparent; @@ -128,6 +128,7 @@ to adjust for the border using a different method */ } .question-wrapper { + z-index: $z-index-stage-question; pointer-events: auto; } @@ -147,6 +148,8 @@ to adjust for the border using a different method */ align-items: center; background: rgba(0,0,0,0.25); border-radius: 0.5rem; + pointer-events: all; + cursor: pointer; } .green-flag-overlay { @@ -154,8 +157,6 @@ to adjust for the border using a different method */ border-radius: 100%; background: rgba(255,255,255,0.75); border: 3px solid $ui-white; - pointer-events: all; - cursor: pointer; display: flex; justify-content: center; align-items: center; diff --git a/src/components/stage/stage.jsx b/src/components/stage/stage.jsx index f87a39b791c..cf81f4ec2c8 100644 --- a/src/components/stage/stage.jsx +++ b/src/components/stage/stage.jsx @@ -48,18 +48,25 @@ const StageComponent = props => { }} onDoubleClick={onDoubleClick} > - + > + + { /> {isStarted ? null : ( - - - + )} {isColorPicking && colorInfo ? ( diff --git a/src/components/stop-all/stop-all.css b/src/components/stop-all/stop-all.css index b2d878582f4..da0bcc7b315 100644 --- a/src/components/stop-all/stop-all.css +++ b/src/components/stop-all/stop-all.css @@ -1,3 +1,5 @@ +@import "../../css/colors.css"; + .stop-all { width: 2rem; height: 2rem; @@ -5,15 +7,14 @@ border-radius: 0.25rem; user-select: none; cursor: pointer; - transition: 0.2s ease-out; } -.stop-all { - opacity: 0.5; +.stop-all:hover { + background-color: $motion-light-transparent; } -.stop-all:hover { - transform: scale(1.2); +.stop-all { + opacity: 0.5; } .stop-all.is-active { diff --git a/src/components/telemetry-modal/telemetry-modal-header.png b/src/components/telemetry-modal/telemetry-modal-header.png new file mode 100644 index 00000000000..01faec7298d Binary files /dev/null and b/src/components/telemetry-modal/telemetry-modal-header.png differ diff --git a/src/components/preview-modal/preview-modal.css b/src/components/telemetry-modal/telemetry-modal.css similarity index 69% rename from src/components/preview-modal/preview-modal.css rename to src/components/telemetry-modal/telemetry-modal.css index 6e17cb6ea08..9354e8fe0ff 100644 --- a/src/components/preview-modal/preview-modal.css +++ b/src/components/telemetry-modal/telemetry-modal.css @@ -20,7 +20,7 @@ padding: 0; border-radius: $space; user-select: none; - width: 500px; + width: 640px; color: $text-primary; overflow: hidden; @@ -28,16 +28,21 @@ .illustration { width: 100%; - height: 208px; + height: 123px; background-color: $motion-primary; - background-image: url('./welcome.png'); + background-image: url('./telemetry-modal-header.png'); background-size: cover; } .body { background: $ui-white; padding: 1.5rem 2.25rem; - text-align: center; + text-align: left; +} + +.privacy-policy-link { + color: $motion-primary; + text-decoration: none; } /* Confirmation buttons at the bottom of the modal */ @@ -59,21 +64,15 @@ cursor: pointer; } -.button-row button.ok-button { +.button-row button.opt-in { background: $motion-primary; color: white; } -.button-row button.no-button { +.button-row button.opt-out { color: $motion-primary; } -.button-row button.view-project-button { - background: $extensions-primary; - border-color: $extensions-primary; - color: white; -} - [dir="ltr"] .button-row button + button { margin-left: 0.5rem; } @@ -81,28 +80,3 @@ [dir="rtl"] .button-row button + button { margin-right: 0.5rem; } - -.cat-icon { - width: 1.5rem; - height: 1.5rem; - vertical-align: middle; -} - -[dir="ltr"] .cat-icon { - margin-left: .125rem; -} - -[dir="rtl"] .cat-icon { - margin-right: .125rem; -} - -.faq-link-text { - margin: 2rem 0 .5rem 0; - font-size: .875rem; - color: $text-primary; -} - -.faq-link { - color: $motion-primary; - text-decoration: none; -} diff --git a/src/components/telemetry-modal/telemetry-modal.jsx b/src/components/telemetry-modal/telemetry-modal.jsx new file mode 100644 index 00000000000..2d3149d3c67 --- /dev/null +++ b/src/components/telemetry-modal/telemetry-modal.jsx @@ -0,0 +1,140 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {defineMessages, injectIntl, intlShape, FormattedMessage} from 'react-intl'; +import ReactModal from 'react-modal'; + +import Box from '../box/box.jsx'; + +import styles from './telemetry-modal.css'; + +const messages = defineMessages({ + label: { + id: 'gui.telemetryOptIn.label', + defaultMessage: 'Report statistics to improve Scratch', + description: 'Scratch 3.0 telemetry modal label - for accessibility' + }, + bodyText1: { + defaultMessage: 'The Scratch Team is always looking to better understand how Scratch is used around the ' + + 'world. To help support this effort, you can allow Scratch to automatically send usage information to ' + + 'the Scratch Team.', + description: 'First paragraph of body text for telemetry opt-in modal', + id: 'gui.telemetryOptIn.body1' + }, + bodyText2: { + defaultMessage: 'The information we collect includes language selection, blocks usage, and some events like ' + + 'saving, loading, and uploading a project. We DO NOT collect any personal information. Please see our ' + + '{privacyPolicyLink} for more information.', + description: 'First paragraph of body text for telemetry opt-in modal', + id: 'gui.telemetryOptIn.body2' + }, + privacyPolicyLink: { + defaultMessage: 'Privacy Policy', + description: 'Link to the Scratch privacy policy', + id: 'gui.telemetryOptIn.privacyPolicyLink' + }, + noButton: { + defaultMessage: 'No, thanks', + description: 'Text for telemetry modal opt-out button', + id: 'gui.telemetryOptIn.buttonTextNo' + }, + noTooltip: { + defaultMessage: 'Disable telemetry', + description: 'Tooltip for telemetry modal opt-out button', + id: 'gui.telemetryOptIn.buttonTooltipNo' + }, + yesButton: { + defaultMessage: "Yes, I'd like to help improve Scratch", + description: 'Text for telemetry modal opt-in button', + id: 'gui.telemetryOptIn.buttonTextYes' + }, + yesTooltip: { + defaultMessage: 'Enable telemetry', + description: 'Tooltip for telemetry modal opt-in button', + id: 'gui.telemetryOptIn.buttonTooltipYes' + } +}); + +class TelemetryModal extends React.PureComponent { + constructor (props) { + super(props); + bindAll(this, [ + 'handleCancel', + 'handleOptIn', + 'handleOptOut' + ]); + } + handleCancel () { + this.props.onRequestClose(); + if (this.props.onCancel) { + this.props.onCancel(); + } + } + handleOptIn () { + this.props.onRequestClose(); + if (this.props.onOptIn) { + this.props.onOptIn(); + } + } + handleOptOut () { + this.props.onRequestClose(); + if (this.props.onOptOut) { + this.props.onOptOut(); + } + } + render () { + return ( +
+ + + +

+

+ + ) + }} + />

+ + + + +
+
+
); + } +} + +TelemetryModal.propTypes = { + intl: intlShape.isRequired, + isRtl: PropTypes.bool, + onCancel: PropTypes.func, + onOptIn: PropTypes.func.isRequired, + onOptOut: PropTypes.func.isRequired, + onRequestClose: PropTypes.func +}; + +export default injectIntl(TelemetryModal); diff --git a/src/components/waveform/waveform.jsx b/src/components/waveform/waveform.jsx index 1b2abe96ebf..48a0a4935c3 100644 --- a/src/components/waveform/waveform.jsx +++ b/src/components/waveform/waveform.jsx @@ -2,52 +2,65 @@ import React from 'react'; import PropTypes from 'prop-types'; import styles from './waveform.css'; -const Waveform = props => { - const { - width, - height, - data - } = props; - - const cappedData = [0, ...data, 0]; - - const points = [ - ...cappedData.map((v, i) => - [width * i / cappedData.length, height * v / 2] - ), - ...cappedData.reverse().map((v, i) => - [width * (cappedData.length - i - 1) / cappedData.length, -height * v / 2] - ) - ]; - - const pathComponents = points.map(([x, y], i) => { - const [nx, ny] = points[i < points.length - 1 ? i + 1 : 0]; - return `Q${x} ${y} ${(x + nx) / 2} ${(y + ny) / 2}`; - }); - - return ( - - - - i % takeEveryN === 0); + + const cappedData = [0, ...filteredData, 0]; + + const points = [ + ...cappedData.map((v, i) => + [width * i / cappedData.length, height * v / 2] + ), + ...cappedData.reverse().map((v, i) => + [width * (cappedData.length - i - 1) / cappedData.length, -height * v / 2] + ) + ]; + const pathComponents = points.map(([x, y], i) => { + const [nx, ny] = points[i < points.length - 1 ? i + 1 : 0]; + return `Q${x} ${y} ${(x + nx) / 2} ${(y + ny) / 2}`; + }); + + return ( + + - - - ); -}; + + + + + ); + } +} Waveform.propTypes = { data: PropTypes.arrayOf(PropTypes.number), diff --git a/src/components/browser-modal/unsupported.png b/src/components/webgl-modal/unsupported.png similarity index 100% rename from src/components/browser-modal/unsupported.png rename to src/components/webgl-modal/unsupported.png diff --git a/src/components/webgl-modal/webgl-modal.css b/src/components/webgl-modal/webgl-modal.css index 10eed71f1d4..85fcc0d0137 100644 --- a/src/components/webgl-modal/webgl-modal.css +++ b/src/components/webgl-modal/webgl-modal.css @@ -30,7 +30,7 @@ width: 100%; height: 208px; background-color: $motion-primary; - background-image: url('../browser-modal/unsupported.png'); + background-image: url('./unsupported.png'); background-size: cover; } diff --git a/src/components/webgl-modal/webgl-modal.jsx b/src/components/webgl-modal/webgl-modal.jsx index faf5ac55171..8c11014eb62 100644 --- a/src/components/webgl-modal/webgl-modal.jsx +++ b/src/components/webgl-modal/webgl-modal.jsx @@ -39,7 +39,7 @@ const WebGlModal = ({intl, ...props}) => ( webGlLink: ( { - this.updateToolbox(); - }, 0); + // Only rerender the toolbox when the blocks are visible and the xml is + // different from the previously rendered toolbox xml. + // Do not check against prevProps.toolboxXML because that may not have been rendered. + if (this.props.isVisible && this.props.toolboxXML !== this._renderedToolboxXML) { + this.requestToolboxUpdate(); } + if (this.props.isVisible === prevProps.isVisible) { if (this.props.stageSize !== prevProps.stageSize) { // force workspace to redraw for the new stage size @@ -158,7 +162,7 @@ class Blocks extends React.Component { this.setLocale(); } else { this.props.vm.refreshWorkspace(); - this.updateToolbox(); + this.requestToolboxUpdate(); } window.dispatchEvent(new Event('resize')); @@ -171,15 +175,22 @@ class Blocks extends React.Component { this.workspace.dispose(); clearTimeout(this.toolboxUpdateTimeout); } - + requestToolboxUpdate () { + clearTimeout(this.toolboxUpdateTimeout); + this.toolboxUpdateTimeout = setTimeout(() => { + this.updateToolbox(); + }, 0); + } setLocale () { - this.workspace.getFlyout().setRecyclingEnabled(false); this.ScratchBlocks.ScratchMsgs.setLocale(this.props.locale); this.props.vm.setLocale(this.props.locale, this.props.messages) .then(() => { + this.workspace.getFlyout().setRecyclingEnabled(false); this.props.vm.refreshWorkspace(); - this.updateToolbox(); - this.workspace.getFlyout().setRecyclingEnabled(true); + this.requestToolboxUpdate(); + this.withToolboxUpdates(() => { + this.workspace.getFlyout().setRecyclingEnabled(true); + }); }); } @@ -189,6 +200,8 @@ class Blocks extends React.Component { const categoryId = this.workspace.toolbox_.getSelectedCategoryId(); const offset = this.workspace.toolbox_.getCategoryScrollOffset(); this.workspace.updateToolbox(this.props.toolboxXML); + this._renderedToolboxXML = this.props.toolboxXML; + // In order to catch any changes that mutate the toolbox during "normal runtime" // (variable changes/etc), re-enable toolbox refresh. // Using the setter function will rerender the entire toolbox which we just rendered. @@ -233,7 +246,7 @@ class Blocks extends React.Component { this.props.vm.addListener('EXTENSION_ADDED', this.handleExtensionAdded); this.props.vm.addListener('BLOCKSINFO_UPDATE', this.handleBlocksInfoUpdate); this.props.vm.addListener('PERIPHERAL_CONNECTED', this.handleStatusButtonUpdate); - this.props.vm.addListener('PERIPHERAL_DISCONNECT_ERROR', this.handleStatusButtonUpdate); + this.props.vm.addListener('PERIPHERAL_DISCONNECTED', this.handleStatusButtonUpdate); } detachVM () { this.props.vm.removeListener('SCRIPT_GLOW_ON', this.onScriptGlowOn); @@ -246,7 +259,7 @@ class Blocks extends React.Component { this.props.vm.removeListener('EXTENSION_ADDED', this.handleExtensionAdded); this.props.vm.removeListener('BLOCKSINFO_UPDATE', this.handleBlocksInfoUpdate); this.props.vm.removeListener('PERIPHERAL_CONNECTED', this.handleStatusButtonUpdate); - this.props.vm.removeListener('PERIPHERAL_DISCONNECT_ERROR', this.handleStatusButtonUpdate); + this.props.vm.removeListener('PERIPHERAL_DISCONNECTED', this.handleStatusButtonUpdate); } updateToolboxBlockValue (id, value) { @@ -262,7 +275,7 @@ class Blocks extends React.Component { } onTargetsUpdate () { - if (this.props.vm.editingTarget) { + if (this.props.vm.editingTarget && this.workspace.getFlyout()) { ['glide', 'move', 'set'].forEach(prefix => { this.updateToolboxBlockValue(`${prefix}x`, Math.round(this.props.vm.editingTarget.x).toString()); this.updateToolboxBlockValue(`${prefix}y`, Math.round(this.props.vm.editingTarget.y).toString()); @@ -297,12 +310,32 @@ class Blocks extends React.Component { onVisualReport (data) { this.workspace.reportValue(data.id, data.value); } + getToolboxXML () { + // Use try/catch because this requires digging pretty deep into the VM + // Code inside intentionally ignores several error situations (no stage, etc.) + // Because they would get caught by this try/catch + try { + let {editingTarget: target, runtime} = this.props.vm; + const stage = runtime.getTargetForStage(); + if (!target) target = stage; // If no editingTarget, use the stage + + const stageCostumes = stage.getCostumes(); + const targetCostumes = target.getCostumes(); + const targetSounds = target.getSounds(); + const dynamicBlocksXML = this.props.vm.runtime.getBlocksXML(); + return makeToolboxXML(target.isStage, target.id, dynamicBlocksXML, + targetCostumes[0].name, + stageCostumes[0].name, + targetSounds.length > 0 ? targetSounds[0].name : '' + ); + } catch { + return null; + } + } onWorkspaceUpdate (data) { // When we change sprites, update the toolbox to have the new sprite's blocks - if (this.props.vm.editingTarget) { - const target = this.props.vm.editingTarget; - const dynamicBlocksXML = this.props.vm.runtime.getBlocksXML(); - const toolboxXML = makeToolboxXML(target.isStage, target.id, dynamicBlocksXML); + const toolboxXML = this.getToolboxXML(); + if (toolboxXML) { this.props.updateToolboxState(toolboxXML); } @@ -350,12 +383,9 @@ class Blocks extends React.Component { // this actually defines blocks and MUST run regardless of the UI state this.ScratchBlocks.defineBlocksWithJsonArray(blocksInfo.map(blockInfo => blockInfo.json).filter(x => x)); - // update the toolbox view: this can be skipped if we're not looking at a target, etc. - const runtime = this.props.vm.runtime; - const target = runtime.getEditingTarget() || runtime.getTargetForStage(); - if (target) { - const dynamicBlocksXML = runtime.getBlocksXML(); - const toolboxXML = makeToolboxXML(target.isStage, target.id, dynamicBlocksXML); + // Update the toolbox with new blocks + const toolboxXML = this.getToolboxXML(); + if (toolboxXML) { this.props.updateToolboxState(toolboxXML); } } @@ -426,6 +456,7 @@ class Blocks extends React.Component { .then(blocks => this.props.vm.shareBlocksToTarget(blocks, this.props.vm.editingTarget.id)) .then(() => { this.props.vm.refreshWorkspace(); + this.updateToolbox(); // To show new variables/custom blocks }); } render () { @@ -460,9 +491,9 @@ class Blocks extends React.Component { /> {this.state.prompt ? ( 2 ? item.info[2] : 1, skinId: null }; - this.props.vm.addCostume(item.md5, vmCostume); + this.props.vm.addCostumeFromLibrary(item.md5, vmCostume); analytics.event({ category: 'library', action: 'Select Costume', diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx index 321c25c9ad1..6be2cf26849 100644 --- a/src/containers/costume-tab.jsx +++ b/src/containers/costume-tab.jsx @@ -13,6 +13,7 @@ import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx'; import DragConstants from '../lib/drag-constants'; import {emptyCostume} from '../lib/empty-assets'; import sharedMessages from '../lib/shared-messages'; +import downloadBlob from '../lib/download-blob'; import { closeCameraCapture, @@ -27,6 +28,7 @@ import { } from '../reducers/editor-tab'; import {setRestore} from '../reducers/restore-deletion'; +import {showStandardAlert, closeAlertWithId} from '../reducers/alerts'; import addLibraryBackdropIcon from '../components/asset-panel/icon--add-backdrop-lib.svg'; import addLibraryCostumeIcon from '../components/asset-panel/icon--add-costume-lib.svg'; @@ -86,6 +88,7 @@ class CostumeTab extends React.Component { 'handleSelectCostume', 'handleDeleteCostume', 'handleDuplicateCostume', + 'handleExportCostume', 'handleNewCostume', 'handleNewBlankCostume', 'handleSurpriseCostume', @@ -150,8 +153,20 @@ class CostumeTab extends React.Component { handleDuplicateCostume (costumeIndex) { this.props.vm.duplicateCostume(costumeIndex); } - handleNewCostume (costume) { - this.props.vm.addCostume(costume.md5, costume); + handleExportCostume (costumeIndex) { + const item = this.props.vm.editingTarget.sprite.costumes[costumeIndex]; + const blob = new Blob([item.asset.data], {type: item.asset.assetType.contentType}); + downloadBlob(`${item.name}.${item.asset.dataFormat}`, blob); + } + handleNewCostume (costume, fromCostumeLibrary) { + const costumes = Array.isArray(costume) ? costume : [costume]; + + return Promise.all(costumes.map(c => { + if (fromCostumeLibrary) { + return this.props.vm.addCostumeFromLibrary(c.md5, c); + } + return this.props.vm.addCostume(c.md5, c); + })); } handleNewBlankCostume () { const name = this.props.vm.editingTarget.isStage ? @@ -173,7 +188,7 @@ class CostumeTab extends React.Component { bitmapResolution: item.info.length > 2 ? item.info[2] : 1, skinId: null }; - this.handleNewCostume(vmCostume); + this.handleNewCostume(vmCostume, true /* fromCostumeLibrary */); } handleSurpriseBackdrop () { const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)]; @@ -189,14 +204,26 @@ class CostumeTab extends React.Component { } handleCostumeUpload (e) { const storage = this.props.vm.runtime.storage; - handleFileUpload(e.target, (buffer, fileType, fileName) => { - costumeUpload(buffer, fileType, fileName, storage, this.handleNewCostume); - }); + this.props.onShowImporting(); + handleFileUpload(e.target, (buffer, fileType, fileName, fileIndex, fileCount) => { + costumeUpload(buffer, fileType, storage, vmCostumes => { + vmCostumes.forEach((costume, i) => { + costume.name = `${fileName}${i ? i + 1 : ''}`; + }); + this.handleNewCostume(vmCostumes).then(() => { + if (fileIndex === fileCount - 1) { + this.props.onCloseImporting(); + } + }); + }, this.props.onCloseImporting); + }, this.props.onCloseImporting); } handleCameraBuffer (buffer) { const storage = this.props.vm.runtime.storage; - const name = this.props.intl.formatMessage(messages.costume, {index: 1}); - costumeUpload(buffer, 'image/png', name, storage, this.handleNewCostume); + costumeUpload(buffer, 'image/png', storage, vmCostumes => { + vmCostumes[0].name = this.props.intl.formatMessage(messages.costume, {index: 1}); + this.handleNewCostume(vmCostumes); + }); } handleFileUploadClick () { this.fileInput.click(); @@ -280,9 +307,10 @@ class CostumeTab extends React.Component { title: intl.formatMessage(addFileMessage), img: fileUploadIcon, onClick: this.handleFileUploadClick, - fileAccept: '.svg, .png, .jpg, .jpeg', + fileAccept: '.svg, .png, .jpg, .jpeg, .gif', fileChange: this.handleCostumeUpload, - fileInput: this.setFileInput + fileInput: this.setFileInput, + fileMultiple: true }, { title: intl.formatMessage(messages.addSurpriseCostumeMsg), @@ -308,6 +336,7 @@ class CostumeTab extends React.Component { this.handleDeleteCostume : null} onDrop={this.handleDrop} onDuplicateClick={this.handleDuplicateCostume} + onExportClick={this.handleExportCostume} onItemClick={this.handleSelectCostume} > {target.costumes ? @@ -334,10 +363,12 @@ CostumeTab.propTypes = { intl: intlShape, isRtl: PropTypes.bool, onActivateSoundsTab: PropTypes.func.isRequired, + onCloseImporting: PropTypes.func.isRequired, onNewCostumeFromCameraClick: PropTypes.func.isRequired, onNewLibraryBackdropClick: PropTypes.func.isRequired, onNewLibraryCostumeClick: PropTypes.func.isRequired, onRequestCloseCameraModal: PropTypes.func.isRequired, + onShowImporting: PropTypes.func.isRequired, sprites: PropTypes.shape({ id: PropTypes.shape({ costumes: PropTypes.arrayOf(PropTypes.shape({ @@ -382,7 +413,9 @@ const mapDispatchToProps = dispatch => ({ }, dispatchUpdateRestore: restoreState => { dispatch(setRestore(restoreState)); - } + }, + onCloseImporting: () => dispatch(closeAlertWithId('importingAsset')), + onShowImporting: () => dispatch(showStandardAlert('importingAsset')) }); export default errorBoundaryHOC('Costume Tab')( diff --git a/src/containers/error-boundary.jsx b/src/containers/error-boundary.jsx index 6485a41b84b..0b7704ba229 100644 --- a/src/containers/error-boundary.jsx +++ b/src/containers/error-boundary.jsx @@ -1,18 +1,17 @@ import React from 'react'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; -import bowser from 'bowser'; import BrowserModalComponent from '../components/browser-modal/browser-modal.jsx'; import CrashMessageComponent from '../components/crash-message/crash-message.jsx'; import log from '../lib/log.js'; -import supportedBrowser from '../lib/supported-browser'; -import analytics from '../lib/analytics'; +import {recommendedBrowser} from '../lib/supported-browser'; class ErrorBoundary extends React.Component { constructor (props) { super(props); this.state = { - hasError: false + hasError: false, + errorId: null }; } @@ -23,24 +22,23 @@ class ErrorBoundary extends React.Component { message: 'Unknown error' }; - // Display fallback UI - this.setState({hasError: true}); - - // Log errors to analytics, separating supported browsers from unsupported. - if (supportedBrowser()) { - analytics.event({ - category: 'error', - action: this.props.action, - label: error.message - }); - } else { - analytics.event({ - category: 'Unsupported Browser Error', - action: `(Unsupported Browser) ${this.props.action}`, - label: `${bowser.name} ${error.message}` + // Log errors to analytics, leaving out browsers that are not in our recommended set + if (recommendedBrowser() && window.Sentry) { + window.Sentry.withScope(scope => { + Object.keys(info).forEach(key => { + scope.setExtra(key, info[key]); + }); + scope.setExtra('action', this.props.action); + window.Sentry.captureException(error); }); } + // Display fallback UI + this.setState({ + hasError: true, + errorId: window.Sentry ? window.Sentry.lastEventId() : null + }); + // Log error locally for debugging as well. log.error(`Unhandled Error: ${error.stack}\nComponent stack: ${info.componentStack}`); } @@ -55,10 +53,16 @@ class ErrorBoundary extends React.Component { render () { if (this.state.hasError) { - if (supportedBrowser()) { - return ; + if (recommendedBrowser()) { + return ( + + ); } return (); diff --git a/src/containers/green-flag-overlay.jsx b/src/containers/green-flag-overlay.jsx index 2d0e4135ff2..d6dcd44ccd7 100644 --- a/src/containers/green-flag-overlay.jsx +++ b/src/containers/green-flag-overlay.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import VM from 'scratch-vm'; +import Box from '../components/box/box.jsx'; import greenFlag from '../components/green-flag/icon--green-flag.svg'; class GreenFlagOverlay extends React.Component { @@ -23,15 +24,18 @@ class GreenFlagOverlay extends React.Component { if (this.props.isStarted) return null; return ( -
- -
+
+ +
+
+ ); } } @@ -39,7 +43,8 @@ class GreenFlagOverlay extends React.Component { GreenFlagOverlay.propTypes = { className: PropTypes.string, isStarted: PropTypes.bool, - vm: PropTypes.instanceOf(VM) + vm: PropTypes.instanceOf(VM), + wrapperClass: PropTypes.string }; const mapStateToProps = state => ({ diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index 9935b3a7e25..173f1218a6e 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -7,7 +7,6 @@ import VM from 'scratch-vm'; import {defineMessages, injectIntl, intlShape} from 'react-intl'; import ErrorBoundaryHOC from '../lib/error-boundary-hoc.jsx'; -import {openExtensionLibrary} from '../reducers/modals'; import { getIsError, getIsShowingProject @@ -22,7 +21,9 @@ import { import { closeCostumeLibrary, - closeBackdropLibrary + closeBackdropLibrary, + closeTelemetryModal, + openExtensionLibrary } from '../reducers/modals'; import FontLoaderHOC from '../lib/font-loader-hoc.jsx'; @@ -36,6 +37,7 @@ import vmManagerHOC from '../lib/vm-manager-hoc.jsx'; import cloudManagerHOC from '../lib/cloud-manager-hoc.jsx'; import GUIComponent from '../components/gui/gui.jsx'; +import {setIsScratchDesktop} from '../lib/isScratchDesktop.js'; const messages = defineMessages({ defaultProjectTitle: { @@ -47,6 +49,7 @@ const messages = defineMessages({ class GUI extends React.Component { componentDidMount () { + setIsScratchDesktop(this.props.isScratchDesktop); this.setReduxTitle(this.props.projectTitle); this.props.onStorageInit(storage); } @@ -57,6 +60,11 @@ class GUI extends React.Component { if (this.props.projectTitle !== prevProps.projectTitle) { this.setReduxTitle(this.props.projectTitle); } + if (this.props.isShowingProject && !prevProps.isShowingProject) { + // this only notifies container when a project changes from not yet loaded to loaded + // At this time the project view in www doesn't need to know when a project is unloaded + this.props.onProjectLoaded(); + } } setReduxTitle (newTitle) { if (newTitle === null || typeof newTitle === 'undefined') { @@ -78,7 +86,9 @@ class GUI extends React.Component { cloudHost, error, isError, + isScratchDesktop, isShowingProject, + onProjectLoaded, onStorageInit, onUpdateProjectId, onUpdateReduxProjectTitle, @@ -109,26 +119,29 @@ GUI.propTypes = { cloudHost: PropTypes.string, error: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), fetchingProject: PropTypes.bool, - importInfoVisible: PropTypes.bool, intl: intlShape, isError: PropTypes.bool, isLoading: PropTypes.bool, + isScratchDesktop: PropTypes.bool, isShowingProject: PropTypes.bool, loadingStateVisible: PropTypes.bool, + onProjectLoaded: PropTypes.func, onSeeCommunity: PropTypes.func, onStorageInit: PropTypes.func, onUpdateProjectId: PropTypes.func, onUpdateProjectTitle: PropTypes.func, onUpdateReduxProjectTitle: PropTypes.func, - previewInfoVisible: PropTypes.bool, projectHost: PropTypes.string, projectId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), projectTitle: PropTypes.string, + telemetryModalVisible: PropTypes.bool, vm: PropTypes.instanceOf(VM).isRequired }; GUI.defaultProps = { + isScratchDesktop: false, onStorageInit: storageInstance => storageInstance.addOfficialScratchWebStores(), + onProjectLoaded: () => {}, onUpdateProjectId: () => {} }; @@ -144,19 +157,19 @@ const mapStateToProps = state => { costumeLibraryVisible: state.scratchGui.modals.costumeLibrary, costumesTabVisible: state.scratchGui.editorTab.activeTabIndex === COSTUMES_TAB_INDEX, error: state.scratchGui.projectState.error, - importInfoVisible: state.scratchGui.modals.importInfo, isError: getIsError(loadingState), + isFullScreen: state.scratchGui.mode.isFullScreen, isPlayerOnly: state.scratchGui.mode.isPlayerOnly, isRtl: state.locales.isRtl, isShowingProject: getIsShowingProject(loadingState), loadingStateVisible: state.scratchGui.modals.loadingProject, - previewInfoVisible: state.scratchGui.modals.previewInfo, projectId: state.scratchGui.projectState.projectId, + soundsTabVisible: state.scratchGui.editorTab.activeTabIndex === SOUNDS_TAB_INDEX, targetIsStage: ( state.scratchGui.targets.stage && state.scratchGui.targets.stage.id === state.scratchGui.targets.editingTarget ), - soundsTabVisible: state.scratchGui.editorTab.activeTabIndex === SOUNDS_TAB_INDEX, + telemetryModalVisible: state.scratchGui.modals.telemetryModal, tipsLibraryVisible: state.scratchGui.modals.tipsLibrary, vm: state.scratchGui.vm }; @@ -169,6 +182,7 @@ const mapDispatchToProps = dispatch => ({ onActivateSoundsTab: () => dispatch(activateTab(SOUNDS_TAB_INDEX)), onRequestCloseBackdropLibrary: () => dispatch(closeBackdropLibrary()), onRequestCloseCostumeLibrary: () => dispatch(closeCostumeLibrary()), + onRequestCloseTelemetryModal: () => dispatch(closeTelemetryModal()), onUpdateReduxProjectTitle: title => dispatch(setProjectTitle(title)) }); diff --git a/src/containers/import-modal.jsx b/src/containers/import-modal.jsx deleted file mode 100644 index 1c75929cf56..00000000000 --- a/src/containers/import-modal.jsx +++ /dev/null @@ -1,115 +0,0 @@ -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 = { - isRtl: PropTypes.bool, - onBack: PropTypes.func.isRequired, - onCancel: PropTypes.func.isRequired, - onViewProject: PropTypes.func -}; - -const mapStateToProps = state => ({ - isRtl: state.locales.isRtl -}); - -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/library-item.jsx b/src/containers/library-item.jsx new file mode 100644 index 00000000000..6177b916fd6 --- /dev/null +++ b/src/containers/library-item.jsx @@ -0,0 +1,149 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import {injectIntl} from 'react-intl'; + +import LibraryItemComponent from '../components/library-item/library-item.jsx'; + +class LibraryItem extends React.PureComponent { + constructor (props) { + super(props); + bindAll(this, [ + 'handleBlur', + 'handleClick', + 'handleFocus', + 'handleKeyPress', + 'handleMouseEnter', + 'handleMouseLeave', + 'rotateIcon', + 'startRotatingIcons', + 'stopRotatingIcons' + ]); + this.state = { + iconIndex: 0, + isRotatingIcon: false + }; + } + componentWillUnmount () { + clearInterval(this.intervalId); + } + handleBlur (id) { + this.handleMouseLeave(id); + } + handleClick (e) { + if (!this.props.disabled) { + this.props.onSelect(this.props.id); + } + e.preventDefault(); + } + handleFocus (id) { + this.handleMouseEnter(id); + } + handleKeyPress (e) { + if (e.key === ' ' || e.key === 'Enter') { + e.preventDefault(); + this.props.onSelect(this.props.id); + } + } + handleMouseEnter () { + this.props.onMouseEnter(this.props.id); + if (this.props.icons && this.props.icons.length) { + this.stopRotatingIcons(); + this.setState({ + isRotatingIcon: true + }, this.startRotatingIcons); + } + } + handleMouseLeave () { + this.props.onMouseLeave(this.props.id); + if (this.props.icons && this.props.icons.length) { + this.setState({ + isRotatingIcon: false + }, this.stopRotatingIcons); + } + } + startRotatingIcons () { + this.rotateIcon(); + this.intervalId = setInterval(this.rotateIcon, 300); + } + stopRotatingIcons () { + if (this.intervalId) { + this.intervalId = clearInterval(this.intervalId); + } + } + rotateIcon () { + const nextIconIndex = (this.state.iconIndex + 1) % this.props.icons.length; + this.setState({iconIndex: nextIconIndex}); + } + curIconMd5 () { + if (this.props.icons && + this.state.isRotatingIcon && + this.state.iconIndex < this.props.icons.length && + this.props.icons[this.state.iconIndex] && + this.props.icons[this.state.iconIndex].baseLayerMD5) { + return this.props.icons[this.state.iconIndex].baseLayerMD5; + } + return this.props.iconMd5; + } + render () { + const iconMd5 = this.curIconMd5(); + const iconURL = iconMd5 ? + `https://cdn.assets.scratch.mit.edu/internalapi/asset/${iconMd5}/get/` : + this.props.iconRawURL; + return ( +
+
+ /> +
- + 5
- 5 +
+ +
-
@@ -49,6 +45,18 @@ exports[`SpriteSelectorItemComponent matches snapshot when given a number and de 480 x 360
+
+ +
+
+ +