diff --git a/.eslintignore b/.eslintignore index 3675648e816b3221576be2393fd3496bb99dad70..93c033154fbdf0e4cb6ef0dd9c31570e2b6cd475 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,2 +1,3 @@ node_modules/* build/* +dist/* diff --git a/.gitignore b/.gitignore index 8f98cc55bfece970be899bd47c999bfd9ae70369..0b8a0db40e62622a65702b0b042181de5136286e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ npm-* # Build /build +/dist /.opt-in # generated translation files diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000000000000000000000000000000000000..5d69158021a19c3cf2923c0630dfd7509f0dd03e --- /dev/null +++ b/.npmignore @@ -0,0 +1,16 @@ +# Mac OS +.DS_Store + +# NPM +/node_modules +npm-* + +# Testing +/.nyc_output +/coverage + +# Build +/.opt-in + +# generated translation files +/translations diff --git a/.travis.yml b/.travis.yml index 59e1d15ba92569c05cd41357603636935a74fff3..55dcc33c0bdc6003ceff02e9d360902a915d87e2 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,12 +5,14 @@ addons: chrome: stable node_js: - 8 +env: + - NODE_ENV=production cache: directories: - node_modules install: -- npm install -- npm update +- npm --production=false install +- npm --production=false update before_deploy: - npm --no-git-tag-version version 0.1.0-prerelease.$(date +%Y%m%d%H%M%S) - git config --global user.email $(git log --pretty=format:"%ae" -n1) diff --git a/package.json b/package.json index f6d243d46cdccc9473727f729fe398806e1e2a03..e6654a757641eb7560515e153598545395e3b02d 100644 --- a/package.json +++ b/package.json @@ -2,14 +2,14 @@ "name": "scratch-gui", "version": "0.1.0", "description": "GraphicaL User Interface for creating and running Scratch 3.0 projects", - "main": "./src/index.js", + "main": "./dist/scratch-gui.js", "scripts": { "build": "npm run clean && webpack --progress --colors --bail", - "clean": "rimraf ./build && mkdirp build", + "clean": "rimraf ./build && mkdirp build && rimraf ./dist && mkdirp dist", "deploy": "touch build/.nojekyll && gh-pages -t -d build -m \"Build for $(git log --pretty=format:%H -n1)\"", "i18n:src": "babel src > tmp.js && rimraf tmp.js && build-i18n-src ./translations/messages/ ./translations/", "start": "webpack-dev-server", - "test": "npm run test:lint && npm run test:unit && NODE_ENV=production npm run build && npm run test:integration", + "test": "npm run test:lint && npm run test:unit && npm run build && npm run test:integration", "test:integration": "jest --runInBand test[\\\\/]integration", "test:lint": "eslint . --ext .js,.jsx", "test:unit": "jest test[\\\\/]unit", @@ -37,7 +37,7 @@ "babel-preset-es2015": "^6.22.0", "babel-preset-react": "^6.22.0", "buffer-loader": "0.0.1", - "chromedriver": "2.37.0", + "chromedriver": "2.38.0", "classnames": "2.2.5", "copy-webpack-plugin": "^4.3.0", "css-loader": "^0.28.7", @@ -89,12 +89,13 @@ "redux-throttle": "0.1.1", "rimraf": "^2.6.1", "scratch-audio": "0.1.0-prerelease.1523977528", - "scratch-blocks": "0.1.0-prerelease.1523542868", + "scratch-blocks": "0.1.0-prerelease.1524267558", "scratch-l10n": "2.0.20180108132626", - "scratch-paint": "0.2.0-prerelease.20180410152401", - "scratch-render": "0.1.0-prerelease.1523453612", + "scratch-paint": "0.2.0-prerelease.20180423143518", + "scratch-render": "0.1.0-prerelease.20180423214437", + "scratch-svg-renderer": "0.1.0-prerelease.20180423193917", "scratch-storage": "0.4.0", - "scratch-vm": "0.1.0-prerelease.1523642820", + "scratch-vm": "0.1.0-prerelease.1524520946", "selenium-webdriver": "3.6.0", "startaudiocontext": "1.2.1", "style-loader": "^0.20.0", diff --git a/src/components/action-menu/action-menu.jsx b/src/components/action-menu/action-menu.jsx index 0fa100a35fdfc4e8e054be395cae0a29231dcefe..09af880cf78fd69168cbe2e8ac52c10169044e7e 100644 --- a/src/components/action-menu/action-menu.jsx +++ b/src/components/action-menu/action-menu.jsx @@ -137,12 +137,12 @@ class ActionMenu extends React.Component { <div className={styles.moreButtonsOuter}> <div className={styles.moreButtons}> {(moreButtons || []).map(({img, title, onClick: handleClick, - fileAccept, fileChange, fileInput}) => { + fileAccept, fileChange, fileInput}, keyId) => { const isComingSoon = !handleClick; const hasFileInput = fileInput; const tooltipId = title; return ( - <div key={tooltipId}> + <div key={`${tooltipId}-${keyId}`}> <button aria-label={title} className={classNames(styles.button, styles.moreButton, { diff --git a/src/components/browser-modal/browser-modal.jsx b/src/components/browser-modal/browser-modal.jsx index 9052d5f57075796500df46e8f172cca3405e0f8f..94a9d8545e78e5df72a8ef29c7a6d5114149deb1 100644 --- a/src/components/browser-modal/browser-modal.jsx +++ b/src/components/browser-modal/browser-modal.jsx @@ -43,34 +43,18 @@ const BrowserModal = ({intl, ...props}) => ( className={styles.backButton} onClick={props.onBack} > - <FormattedMessage - defaultMessage="Back" - description="Label for button go back when browser is unsupported" - id="gui.unsupportedBrowser.back" - /> + Back </button> </Box> <div className={styles.faqLinkText}> - <FormattedMessage - defaultMessage="To learn more, go to the {previewFaqLink}." - description="Scratch 3.0 FAQ description" - id="gui.unsupportedBrowser.previewfaq" - values={{ - previewFaqLink: ( - <a - className={styles.faqLink} - href="//scratch.mit.edu/preview-faq" - > - <FormattedMessage - defaultMessage="preview FAQ" - description="link to Scratch 3.0 FAQ page" - id="gui.unsupportedBrowser.previewfaqlink" - /> - </a> - ) - }} - /> + To learn more, go to the {' '} + <a + className={styles.faqLink} + href="//scratch.mit.edu/preview-faq" + > + preview FAQ + </a>. </div> </Box> </ReactModal> diff --git a/src/components/button/button.css b/src/components/button/button.css index 5e69aabd477b6507ef774c9249586cdeb29b8198..9ce746c52140b73be1ba889fae9c028ab7cfe2bb 100644 --- a/src/components/button/button.css +++ b/src/components/button/button.css @@ -7,6 +7,7 @@ align-items: center; padding-left: .75rem; padding-right: .75rem; + user-select: none; } .icon { diff --git a/src/components/button/button.jsx b/src/components/button/button.jsx index 1d348ba24d7bfc3ce60a02b244e36e2f9f2b3373..a4c9ffe48b9ab6eba6965e8b018243a43936c151 100644 --- a/src/components/button/button.jsx +++ b/src/components/button/button.jsx @@ -48,7 +48,7 @@ ButtonComponent.propTypes = { disabled: PropTypes.bool, iconClassName: PropTypes.string, iconSrc: PropTypes.string, - onClick: PropTypes.func.isRequired + onClick: PropTypes.func }; export default ButtonComponent; diff --git a/src/components/close-button/close-button.jsx b/src/components/close-button/close-button.jsx index befac229f5b0a5da58f3c1e1b25b6d9d64c79357..98cf78d026015117e61e6814f1b4c44e0c1c6ff2 100644 --- a/src/components/close-button/close-button.jsx +++ b/src/components/close-button/close-button.jsx @@ -4,7 +4,7 @@ import classNames from 'classnames'; import styles from './close-button.css'; import closeIcon from './icon--close.svg'; -import backIcon from './icon--back.svg'; +import backIcon from '../../lib/assets/icon--back.svg'; const CloseButton = props => ( <div diff --git a/src/components/divider/divider.css b/src/components/divider/divider.css new file mode 100644 index 0000000000000000000000000000000000000000..47ce3a29671a4e117603d16704cef578347654cd --- /dev/null +++ b/src/components/divider/divider.css @@ -0,0 +1,5 @@ +@import "../../css/colors.css"; + +.divider { + border-right: 1px dashed $ui-black-transparent; +} diff --git a/src/components/divider/divider.jsx b/src/components/divider/divider.jsx new file mode 100644 index 0000000000000000000000000000000000000000..38975fd0bada5b29ac1bba296a7c1925d88badf3 --- /dev/null +++ b/src/components/divider/divider.jsx @@ -0,0 +1,15 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import styles from './divider.css'; + +const Divider = ({className}) => ( + <div className={classNames(styles.divider, className)} /> +); + +Divider.propTypes = { + className: PropTypes.string +}; + +export default Divider; diff --git a/src/components/filter/filter.css b/src/components/filter/filter.css index a80fd4159a562a4850e88b1bdbdc78892558289f..b67e60795c29a100c7e67982b8dafeb8bb46c28b 100644 --- a/src/components/filter/filter.css +++ b/src/components/filter/filter.css @@ -5,27 +5,28 @@ display: flex; flex-direction: row; align-items: center; - /* - Should we use width 100% instead, for cases when component's - parent is not a flexbox container? - */ flex-grow: 1; - padding: 0.3rem 0.5rem; - background: $ui-black-transparent; - border: 1px solid $ui-white-transparent; + background: $ui-white; border-radius: 10rem; user-select: none; -} + height: $library-filter-bar-height; -.filter:hover { - background: $ui-black-transparent; + position: relative; } .filter-icon { - height: 0.9rem; - width: 0.9rem; - margin: 0 0.5rem 0 0.75rem; + position: absolute; + top: 0; + left: 0; + + height: 1rem; + width: 1rem; + margin: 0.75rem 0.75rem 0.75rem 1rem; +} + +.filter:focus-within { + box-shadow: 0 0 0 .25rem $motion-transparent; } /* @@ -33,6 +34,9 @@ */ .x-icon-wrapper { opacity: 0; + position: absolute; + top: 0; + right: 0; display: flex; justify-content: center; @@ -41,7 +45,7 @@ overflow: hidden; /* Mask the icon animation */ height: 1.25rem; width: 1.25rem; - margin: 0 0.25rem 0 0.5rem; /* @todo: move to parent to make component*/ + margin: 0.625rem; border-radius: 50%; pointer-events: none; @@ -83,21 +87,22 @@ .filter-input { flex-grow: 1; - line-height: 1.25rem; + height: $library-filter-bar-height; background-color: transparent; -webkit-appearance: none; outline: none; border: 0; - color: white; + color: $text-primary; font-size: 0.75rem; letter-spacing: 0.15px; cursor: text; + padding: .625rem 2rem .625rem 3rem; } .filter-input::placeholder { - opacity: 0.75; + opacity: .5; padding: 0 0 0 0.25rem; - color: white; - font-size: 0.75rem; + color: $text-primary; + font-size: 0.875rem; letter-spacing: 0.15px; } diff --git a/src/components/filter/filter.jsx b/src/components/filter/filter.jsx index 6ccf4fdd49eb64e5537957436d0e6597a1216258..ab58e99f712408e723edbfc239aa5884dee05c15 100644 --- a/src/components/filter/filter.jsx +++ b/src/components/filter/filter.jsx @@ -8,15 +8,16 @@ import styles from './filter.css'; const FilterComponent = props => { const { + className, onChange, onClear, placeholderText, - filterQuery + filterQuery, + inputClassName } = props; return ( <div - className={classNames({ - [styles.filter]: true, + className={classNames(className, styles.filter, { [styles.isActive]: filterQuery.length > 0 })} > @@ -25,7 +26,7 @@ const FilterComponent = props => { src={filterIcon} /> <input - className={styles.filterInput} + className={classNames(styles.filterInput, inputClassName)} placeholder={placeholderText} type="text" value={filterQuery} @@ -45,12 +46,14 @@ const FilterComponent = props => { }; FilterComponent.propTypes = { + className: PropTypes.string, filterQuery: PropTypes.string, + inputClassName: PropTypes.string, onChange: PropTypes.func, onClear: PropTypes.func, placeholderText: PropTypes.string }; FilterComponent.defaultProps = { - placeholderText: 'what are you looking for?' + placeholderText: 'Search' }; export default FilterComponent; diff --git a/src/components/filter/icon--filter.svg b/src/components/filter/icon--filter.svg index 2a0c75fcb9ffc6aa604b66b0ce6320994e2b086a..400bd6234eb1589ae3f5c9f9c64568d702c62bd4 100644 Binary files a/src/components/filter/icon--filter.svg and b/src/components/filter/icon--filter.svg differ diff --git a/src/components/filter/icon--x.svg b/src/components/filter/icon--x.svg index e78a2a8e24a8fe526004cd1ec4e1a2622dec76d2..88b52e87007a6bb84576180dfd0364f548271753 100644 Binary files a/src/components/filter/icon--x.svg and b/src/components/filter/icon--x.svg differ diff --git a/src/components/language-selector/language-selector.jsx b/src/components/language-selector/language-selector.jsx index e4779d8c6b561d3df41575af07979fdc805d8fbb..befe4797681a1097ad47c05f360129b045658b65 100644 --- a/src/components/language-selector/language-selector.jsx +++ b/src/components/language-selector/language-selector.jsx @@ -53,7 +53,7 @@ const LanguageSelector = ({ LanguageSelector.propTypes = { currentLocale: PropTypes.string, onChange: PropTypes.func, - open: PropTypes.boolean + open: PropTypes.bool }; export default LanguageSelector; diff --git a/src/components/library/library.css b/src/components/library/library.css index 8eb86eec0982892ce49b8b1fbd343af7524f6285..5a2b6e1f0f47a9f58822298f791453d860ed5042 100644 --- a/src/components/library/library.css +++ b/src/components/library/library.css @@ -1,23 +1,6 @@ @import "../../css/units.css"; @import "../../css/colors.css"; -.modal-content { - position: absolute; - - display: flex; - height: 100%; - width: 100%; - - overflow-y: auto; - -webkit-overflow-scrolling: 'touch'; - user-select: none; - - /* Default modal resets */ - margin: 0; - border: none; - border-radius: 0; -} - .library-scroll-grid { display: flex; justify-content: flex-start; @@ -29,3 +12,49 @@ height: calc(100% - $library-header-height); padding: 0.5rem; } + +.library-scroll-grid.withFilterBar { + height: calc(100% - $library-header-height - $library-filter-bar-height - 2rem); +} + +.filter-bar { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + height: calc($library-filter-bar-height + 2rem); /* padding */ + background-color: $motion-transparent; + padding: 0 1rem; + font-size: .875rem; +} + +.filter-bar-item { + margin-right: .75rem; +} + +.filter { + flex-grow: 0; +} + +.filter-input { + width: 11.5rem; + transition: .2s; +} + +.filter-input:focus, +.filter-input:not([value=""]) { + width: 18.75rem; +} + +.divider { + transform: scaleY(1.39); + height: $library-filter-bar-height; +} + +.tag-wrapper { + display: flex; + flex-direction: row; + flex-wrap: wrap; + height: $library-filter-bar-height; + overflow: hidden; +} diff --git a/src/components/library/library.jsx b/src/components/library/library.jsx index 1592e99c51bcf0226a72cd7438fc4089a919bee6..e9996d4f2ea4716f35c650e713605aa803a5ff31 100644 --- a/src/components/library/library.jsx +++ b/src/components/library/library.jsx @@ -1,12 +1,19 @@ +import classNames from 'classnames'; import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; import LibraryItem from '../library-item/library-item.jsx'; -import ModalComponent from '../modal/modal.jsx'; +import Modal from '../../containers/modal.jsx'; +import Divider from '../divider/divider.jsx'; +import Filter from '../filter/filter.jsx'; +import TagButton from '../../containers/tag-button.jsx'; import styles from './library.css'; +const ALL_TAG_TITLE = 'All'; +const tagListPrefix = [{title: ALL_TAG_TITLE}]; + class LibraryComponent extends React.Component { constructor (props) { super(props); @@ -17,11 +24,13 @@ class LibraryComponent extends React.Component { 'handleFocus', 'handleMouseEnter', 'handleMouseLeave', - 'handleSelect' + 'handleSelect', + 'handleTagClick' ]); this.state = { selectedItem: null, - filterQuery: '' + filterQuery: '', + selectedTag: ALL_TAG_TITLE.toLowerCase() }; } handleBlur (id) { @@ -34,6 +43,12 @@ class LibraryComponent extends React.Component { this.props.onRequestClose(); this.props.onItemSelected(this.getFilteredData()[id]); } + handleTagClick (tag) { + this.setState({ + filterQuery: '', + selectedTag: tag.toLowerCase() + }); + } handleMouseEnter (id) { if (this.props.onItemMouseEnter) this.props.onItemMouseEnter(this.getFilteredData()[id]); } @@ -41,26 +56,82 @@ class LibraryComponent extends React.Component { if (this.props.onItemMouseLeave) this.props.onItemMouseLeave(this.getFilteredData()[id]); } handleFilterChange (event) { - this.setState({filterQuery: event.target.value}); + this.setState({ + filterQuery: event.target.value, + selectedTag: ALL_TAG_TITLE.toLowerCase() + }); } handleFilterClear () { this.setState({filterQuery: ''}); } getFilteredData () { - return this.props.data.filter(dataItem => - dataItem.name.toLowerCase().indexOf(this.state.filterQuery.toLowerCase()) !== -1); + if (this.state.selectedTag === 'all') { + if (!this.state.filterQuery) return this.props.data; + return this.props.data.filter(dataItem => ( + (dataItem.tags || []) + // Second argument to map sets `this` + .map(String.prototype.toLowerCase.call, String.prototype.toLowerCase) + .concat(dataItem.name.toLowerCase()) + .join('\n') // unlikely to partially match newlines + .indexOf(this.state.filterQuery.toLowerCase()) !== -1 + )); + } + return this.props.data.filter(dataItem => ( + dataItem.tags && + dataItem.tags + .map(String.prototype.toLowerCase.call, String.prototype.toLowerCase) + .indexOf(this.state.selectedTag) !== -1 + )); } render () { return ( - <ModalComponent - className={styles.modalContent} + <Modal + fullScreen contentLabel={this.props.title} - filterQuery={this.state.filterQuery} - onFilterChange={this.handleFilterChange} - onFilterClear={this.handleFilterClear} + id={this.props.id} onRequestClose={this.props.onRequestClose} > - <div className={styles.libraryScrollGrid}> + {(this.props.filterable || this.props.tags) && ( + <div className={styles.filterBar}> + {this.props.filterable && ( + <Filter + className={classNames( + styles.filterBarItem, + styles.filter + )} + filterQuery={this.state.filterQuery} + inputClassName={styles.filterInput} + onChange={this.handleFilterChange} + onClear={this.handleFilterClear} + /> + )} + {this.props.filterable && this.props.tags && ( + <Divider className={classNames(styles.filterBarItem, styles.divider)} /> + )} + {this.props.tags && + <div className={styles.tagWrapper}> + {tagListPrefix.concat(this.props.tags).map((tagProps, id) => ( + <TagButton + active={this.state.selectedTag === tagProps.title.toLowerCase()} + className={classNames( + styles.filterBarItem, + styles.tagButton, + tagProps.className + )} + key={`tag-button-${id}`} + onClick={this.handleTagClick} + {...tagProps} + /> + ))} + </div> + } + </div> + )} + <div + className={classNames(styles.libraryScrollGrid, { + [styles.withFilterBar]: this.props.filterable || this.props.tags + })} + > {this.getFilteredData().map((dataItem, index) => { const scratchURL = dataItem.md5 ? `https://cdn.assets.scratch.mit.edu/internalapi/asset/${dataItem.md5}/get/` : @@ -83,7 +154,7 @@ class LibraryComponent extends React.Component { ); })} </div> - </ModalComponent> + </Modal> ); } } @@ -100,11 +171,18 @@ LibraryComponent.propTypes = { }) /* eslint-enable react/no-unused-prop-types, lines-around-comment */ ), + filterable: PropTypes.bool, + id: PropTypes.string.isRequired, onItemMouseEnter: PropTypes.func, onItemMouseLeave: PropTypes.func, onItemSelected: PropTypes.func, onRequestClose: PropTypes.func, + tags: PropTypes.arrayOf(PropTypes.shape(TagButton.propTypes)), title: PropTypes.string.isRequired }; +LibraryComponent.defaultProps = { + filterable: true +}; + export default LibraryComponent; diff --git a/src/components/menu-bar/menu-bar.css b/src/components/menu-bar/menu-bar.css index 4805e302dd0f24cecae0d4257d4a371fd71bafe6..1a26960a4581daece08d44e088419878e0644723 100644 --- a/src/components/menu-bar/menu-bar.css +++ b/src/components/menu-bar/menu-bar.css @@ -81,7 +81,6 @@ } .divider { - border-right: 1px dashed $ui-black-transparent; margin: 0 .5rem; height: 34px; } diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 633b8130dd600fa91abf2b366971750162dc37f6..f647b24df534aa6e96ee65917dc59b5b056a0cb7 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -7,6 +7,7 @@ import React from 'react'; import Box from '../box/box.jsx'; import Button from '../button/button.jsx'; import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx'; +import Divider from '../divider/divider.jsx'; import LanguageSelector from '../../containers/language-selector.jsx'; import ProjectLoader from '../../containers/project-loader.jsx'; import Menu from '../../containers/menu.jsx'; @@ -181,7 +182,7 @@ const MenuBar = props => ( </MenuBarMenu> </div> </div> - <div className={classNames(styles.divider)} /> + <Divider className={classNames(styles.divider)} /> <div className={classNames(styles.menuBarItem)}> <MenuBarItemTooltip id="title-field"> <input diff --git a/src/components/modal/modal.css b/src/components/modal/modal.css index 9ece5078663f0e367dd4ef3bb4af59c001c763cc..46794639d82f5a61eb7f6e084036519c0a913e2e 100644 --- a/src/components/modal/modal.css +++ b/src/components/modal/modal.css @@ -24,6 +24,26 @@ overflow: hidden; } +.modal-content.full-screen { + position: absolute; + + display: flex; + height: 100%; + width: 100%; + + overflow-y: auto; + -webkit-overflow-scrolling: 'touch'; + user-select: none; + + background-color: $ui-secondary; + + /* Default modal resets */ + margin: 0; + border: none; + border-radius: 0; +} + + /* Modal header has 3 items: |filter title x| @@ -71,9 +91,25 @@ $sides: 20rem; user-select: none; letter-spacing: 0.4px; cursor: default; + margin: 0 -$sides 0 0; +} + +.full-screen .header-item-title { + margin: 0 0 0 -$sides; } .header-item-close { flex-basis: $sides; justify-content: flex-end; + z-index: 1; +} + +.full-screen .header-item-close { + order: -1; + justify-content: flex-start; +} + +.back-button { + font-weight: normal; + padding-left: 0; } diff --git a/src/components/modal/modal.jsx b/src/components/modal/modal.jsx index 3573e684cf6d32722dcc851709d921095d2f92bc..0683b9bba778394ed82b91f8c2dceeb321aa931f 100644 --- a/src/components/modal/modal.jsx +++ b/src/components/modal/modal.jsx @@ -4,70 +4,71 @@ import React from 'react'; import ReactModal from 'react-modal'; import Box from '../box/box.jsx'; +import Button from '../button/button.jsx'; import CloseButton from '../close-button/close-button.jsx'; -import Filter from '../filter/filter.jsx'; + +import backIcon from '../../lib/assets/icon--back.svg'; import styles from './modal.css'; -class ModalComponent extends React.Component { - render () { - return ( - <ReactModal - isOpen - className={classNames(styles.modalContent, this.props.className)} - contentLabel={this.props.contentLabel} - overlayClassName={styles.modalOverlay} - ref={m => (this.modal = m)} - onRequestClose={this.props.onRequestClose} - > - <Box - direction="column" - grow={1} +const ModalComponent = props => ( + <ReactModal + isOpen + className={classNames(styles.modalContent, props.className, { + [styles.fullScreen]: props.fullScreen + })} + contentLabel={props.contentLabel} + overlayClassName={styles.modalOverlay} + onRequestClose={props.onRequestClose} + > + <Box + direction="column" + grow={1} + > + <div className={styles.header}> + <div + className={classNames( + styles.headerItem, + styles.headerItemTitle + )} > - <div className={styles.header}> - <div className={classNames(styles.headerItem, styles.headerItemFilter)}> - {this.props.onFilterChange ? ( - <Filter - filterQuery={this.props.filterQuery} - onChange={this.props.onFilterChange} - onClear={this.props.onFilterClear} - /> - ) : null} - </div> - <div - className={classNames( - styles.headerItem, - styles.headerItemTitle - )} - > - {this.props.contentLabel} - </div> - <div - className={classNames( - styles.headerItem, - styles.headerItemClose - )} + {props.contentLabel} + </div> + <div + className={classNames( + styles.headerItem, + styles.headerItemClose + )} + > + {props.fullScreen ? ( + <Button + className={styles.backButton} + iconSrc={backIcon} + onClick={props.onRequestClose} > - <CloseButton - size={CloseButton.SIZE_LARGE} - onClick={this.props.onRequestClose} - /> - </div> - </div> - {this.props.children} - </Box> - </ReactModal> - ); - } -} + Back + </Button> + ) : ( + <CloseButton + size={CloseButton.SIZE_LARGE} + onClick={props.onRequestClose} + /> + )} + </div> + </div> + {props.children} + </Box> + </ReactModal> +); ModalComponent.propTypes = { children: PropTypes.node, className: PropTypes.string, - contentLabel: PropTypes.string.isRequired, - filterQuery: PropTypes.string, - onFilterChange: PropTypes.func, - onFilterClear: PropTypes.func, + contentLabel: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object + ]).isRequired, + fullScreen: PropTypes.bool, onRequestClose: PropTypes.func }; diff --git a/src/components/stage-selector/stage-selector.jsx b/src/components/stage-selector/stage-selector.jsx index 8af363c09312afda80c554e75ecd0627b923c362..66fbc1fe9e20bf3afdc3944daa45091e94fe6d1b 100644 --- a/src/components/stage-selector/stage-selector.jsx +++ b/src/components/stage-selector/stage-selector.jsx @@ -32,16 +32,19 @@ const messages = defineMessages({ addBackdropFromFile: { id: 'gui.stageSelector.addBackdropFromFile', description: 'Button to add a stage in the target pane from file', - defaultMessage: 'Coming Soon' + defaultMessage: 'Upload Backdrop' } }); const StageSelector = props => { const { backdropCount, + fileInputRef, intl, selected, url, + onBackdropFileUploadClick, + onBackdropFileUpload, onClick, onNewBackdropClick, onSurpriseBackdropClick, @@ -81,7 +84,11 @@ const StageSelector = props => { moreButtons={[ { title: intl.formatMessage(messages.addBackdropFromFile), - img: fileUploadIcon + img: fileUploadIcon, + onClick: onBackdropFileUploadClick, + fileAccept: '.svg, .png, .jpg, .jpeg', // Bitmap coming soon + fileChange: onBackdropFileUpload, + fileInput: fileInputRef }, { title: intl.formatMessage(messages.addBackdropFromSurprise), img: surpriseIcon, @@ -102,7 +109,10 @@ const StageSelector = props => { StageSelector.propTypes = { backdropCount: PropTypes.number.isRequired, + fileInputRef: PropTypes.func, intl: intlShape.isRequired, + onBackdropFileUpload: PropTypes.func, + onBackdropFileUploadClick: PropTypes.func, onClick: PropTypes.func, onEmptyBackdropClick: PropTypes.func, onNewBackdropClick: PropTypes.func, diff --git a/src/components/tag-button/tag-button.css b/src/components/tag-button/tag-button.css new file mode 100644 index 0000000000000000000000000000000000000000..b770045138c95a7751d13c4947d28ea32cb3f38f --- /dev/null +++ b/src/components/tag-button/tag-button.css @@ -0,0 +1,19 @@ +@import "../../css/colors.css"; +@import "../../css/units.css"; + +.tag-button { + padding: .625rem 1rem; + background: $motion-primary; + border-radius: 1.375rem; + color: $ui-white; + height: $library-filter-bar-height; +} + +.tag-button-icon { + max-width: 1rem; + max-height: 1rem; +} + +.active { + background: $data-primary; +} diff --git a/src/components/tag-button/tag-button.jsx b/src/components/tag-button/tag-button.jsx new file mode 100644 index 0000000000000000000000000000000000000000..5e1318f90d993ea74d3ac59faf5719a522fe7ef6 --- /dev/null +++ b/src/components/tag-button/tag-button.jsx @@ -0,0 +1,46 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import Button from '../button/button.jsx'; + +import styles from './tag-button.css'; + +const TagButtonComponent = ({ + active, + iconClassName, + className, + title, + ...props +}) => ( + <Button + className={classNames( + styles.tagButton, + className, { + [styles.active]: active + } + )} + iconClassName={classNames( + styles.tagButtonIcon, + iconClassName + )} + {...props} + > + {title} + </Button> +); + +TagButtonComponent.propTypes = { + ...Button.propTypes, + active: PropTypes.bool, + title: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object // FormattedMessage + ]).isRequired +}; + +TagButtonComponent.defaultProps = { + active: false +}; + +export default TagButtonComponent; diff --git a/src/components/target-pane/target-pane.jsx b/src/components/target-pane/target-pane.jsx index 7cc7a1ed25963029ea60a6fd3decfb69c52722df..4be489f6948e718c7fe8c2cecb90c44db388726a 100644 --- a/src/components/target-pane/target-pane.jsx +++ b/src/components/target-pane/target-pane.jsx @@ -89,9 +89,11 @@ const spriteShape = PropTypes.shape({ costume: PropTypes.shape({ url: PropTypes.string, name: PropTypes.string.isRequired, - bitmapResolution: PropTypes.number.isRequired, - rotationCenterX: PropTypes.number.isRequired, - rotationCenterY: PropTypes.number.isRequired + // The following are optional because costumes uploaded from disk + // will not have these properties available + bitmapResolution: PropTypes.number, + rotationCenterX: PropTypes.number, + rotationCenterY: PropTypes.number }), direction: PropTypes.number, id: PropTypes.string, diff --git a/src/containers/backdrop-library.jsx b/src/containers/backdrop-library.jsx index 83dacab9df8b1d1dddd08a90938e634ba87bf5c6..98604bf18918ee3c30fd687d53f2820a5a7e8a15 100644 --- a/src/containers/backdrop-library.jsx +++ b/src/containers/backdrop-library.jsx @@ -1,12 +1,22 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; +import {defineMessages, injectIntl, intlShape} from 'react-intl'; import VM from 'scratch-vm'; import analytics from '../lib/analytics'; import backdropLibraryContent from '../lib/libraries/backdrops.json'; +import backdropTags from '../lib/libraries/backdrop-tags'; import LibraryComponent from '../components/library/library.jsx'; +const messages = defineMessages({ + libraryTitle: { + defaultMessage: 'Choose a Backdrop', + description: 'Heading for the backdrop library', + id: 'gui.costumeLibrary.chooseABackdrop' + } +}); + class BackdropLibrary extends React.Component { constructor (props) { @@ -34,7 +44,9 @@ class BackdropLibrary extends React.Component { return ( <LibraryComponent data={backdropLibraryContent} - title="Choose a Backdrop" + id="backdropLibrary" + tags={backdropTags} + title={this.props.intl.formatMessage(messages.libraryTitle)} onItemSelected={this.handleItemSelect} onRequestClose={this.props.onRequestClose} /> @@ -43,8 +55,9 @@ class BackdropLibrary extends React.Component { } BackdropLibrary.propTypes = { + intl: intlShape.isRequired, onRequestClose: PropTypes.func, vm: PropTypes.instanceOf(VM).isRequired }; -export default BackdropLibrary; +export default injectIntl(BackdropLibrary); diff --git a/src/containers/costume-library.jsx b/src/containers/costume-library.jsx index 3c1e639e26aee884052381e4d0a430c3c1748b1f..fdef46f07fe03192469f3e79583193f77de9d038 100644 --- a/src/containers/costume-library.jsx +++ b/src/containers/costume-library.jsx @@ -1,12 +1,22 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; +import {defineMessages, injectIntl, intlShape} from 'react-intl'; import VM from 'scratch-vm'; import analytics from '../lib/analytics'; import costumeLibraryContent from '../lib/libraries/costumes.json'; +import spriteTags from '../lib/libraries/sprite-tags'; import LibraryComponent from '../components/library/library.jsx'; +const messages = defineMessages({ + libraryTitle: { + defaultMessage: 'Choose a Costume', + description: 'Heading for the costume library', + id: 'gui.costumeLibrary.chooseACostume' + } +}); + class CostumeLibrary extends React.PureComponent { constructor (props) { @@ -34,7 +44,9 @@ class CostumeLibrary extends React.PureComponent { return ( <LibraryComponent data={costumeLibraryContent} - title="Choose a Costume" + id="costumeLibrary" + tags={spriteTags} + title={this.props.intl.formatMessage(messages.libraryTitle)} onItemSelected={this.handleItemSelected} onRequestClose={this.props.onRequestClose} /> @@ -43,8 +55,9 @@ class CostumeLibrary extends React.PureComponent { } CostumeLibrary.propTypes = { + intl: intlShape.isRequired, onRequestClose: PropTypes.func, vm: PropTypes.instanceOf(VM).isRequired }; -export default CostumeLibrary; +export default injectIntl(CostumeLibrary); diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx index 43a98bd430321e6d894279509ba39758a00de867..6042ceaaac8003eb03e4594faa2a91eb5b563b77 100644 --- a/src/containers/costume-tab.jsx +++ b/src/containers/costume-tab.jsx @@ -9,6 +9,7 @@ import PaintEditorWrapper from './paint-editor-wrapper.jsx'; import CostumeLibrary from './costume-library.jsx'; import BackdropLibrary from './backdrop-library.jsx'; import {connect} from 'react-redux'; +import {handleFileUpload, costumeUpload} from '../lib/file-uploader.js'; import { closeCostumeLibrary, @@ -48,9 +49,14 @@ const messages = defineMessages({ description: 'Button to add a surprise costume in the editor tab', id: 'gui.costumeTab.addSurpriseCostume' }, + addFileBackdropMsg: { + defaultMessage: 'Upload Backdrop', + description: 'Button to add a backdrop by uploading a file in the editor tab', + id: 'gui.costumeTab.addFileBackdrop' + }, addFileCostumeMsg: { - defaultMessage: 'Coming Soon', - description: 'Button to add a file upload costume in the editor tab', + defaultMessage: 'Upload Costume', + description: 'Button to add a costume by uploading a file in the editor tab', id: 'gui.costumeTab.addFileCostume' }, addCameraCostumeMsg: { @@ -67,9 +73,13 @@ class CostumeTab extends React.Component { 'handleSelectCostume', 'handleDeleteCostume', 'handleDuplicateCostume', + 'handleNewCostume', 'handleNewBlankCostume', 'handleSurpriseCostume', - 'handleSurpriseBackdrop' + 'handleSurpriseBackdrop', + 'handleFileUploadClick', + 'handleCostumeUpload', + 'setFileInput' ]); const { editingTarget, @@ -121,6 +131,9 @@ class CostumeTab extends React.Component { handleDuplicateCostume (costumeIndex) { this.props.vm.duplicateCostume(costumeIndex); } + handleNewCostume (costume) { + this.props.vm.addCostume(costume.md5, costume); + } handleNewBlankCostume () { const emptyItem = costumeLibraryContent.find(item => ( item.name === 'Empty' @@ -128,35 +141,50 @@ class CostumeTab extends React.Component { const name = this.props.vm.editingTarget.isStage ? `backdrop1` : `costume1`; const vmCostume = { name: name, + md5: emptyItem.md5, rotationCenterX: emptyItem.info[0], rotationCenterY: emptyItem.info[1], bitmapResolution: emptyItem.info.length > 2 ? emptyItem.info[2] : 1, skinId: null }; - this.props.vm.addCostume(emptyItem.md5, vmCostume); + this.handleNewCostume(vmCostume); } handleSurpriseCostume () { const item = costumeLibraryContent[Math.floor(Math.random() * costumeLibraryContent.length)]; const vmCostume = { name: item.name, + md5: item.md5, 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); + this.handleNewCostume(vmCostume); } handleSurpriseBackdrop () { const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)]; const vmCostume = { name: item.name, + md5: item.md5, 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); + this.handleNewCostume(vmCostume); + } + handleCostumeUpload (e) { + const storage = this.props.vm.runtime.storage; + handleFileUpload(e.target, (buffer, fileType, fileName) => { + costumeUpload(buffer, fileType, fileName, storage, this.handleNewCostume); + }); + } + handleFileUploadClick () { + this.fileInput.click(); + } + setFileInput (input) { + this.fileInput = input; } formatCostumeDetails (size) { // Round up width and height for scratch-flash compatibility @@ -185,6 +213,7 @@ class CostumeTab extends React.Component { } const addLibraryMessage = target.isStage ? messages.addLibraryBackdropMsg : messages.addLibraryCostumeMsg; + const addFileMessage = target.isStage ? messages.addFileBackdropMsg : messages.addFileCostumeMsg; const addSurpriseFunc = target.isStage ? this.handleSurpriseBackdrop : this.handleSurpriseCostume; const addLibraryFunc = target.isStage ? onNewLibraryBackdropClick : onNewLibraryCostumeClick; const addLibraryIcon = target.isStage ? addLibraryBackdropIcon : addLibraryCostumeIcon; @@ -208,8 +237,12 @@ class CostumeTab extends React.Component { img: cameraIcon }, { - title: intl.formatMessage(messages.addFileCostumeMsg), - img: fileUploadIcon + title: intl.formatMessage(addFileMessage), + img: fileUploadIcon, + onClick: this.handleFileUploadClick, + fileAccept: '.svg, .png, .jpg, .jpeg', // coming soon + fileChange: this.handleCostumeUpload, + fileInput: this.setFileInput }, { title: intl.formatMessage(messages.addSurpriseCostumeMsg), diff --git a/src/containers/custom-procedures.jsx b/src/containers/custom-procedures.jsx index dfc00b5bc8bf1af27b8faaec54254571b8022d15..7605fd4b6501388c40a111667b4f80b5dada8d65 100644 --- a/src/containers/custom-procedures.jsx +++ b/src/containers/custom-procedures.jsx @@ -75,7 +75,7 @@ class CustomProcedures extends React.Component { this.props.onRequestClose(); } handleOk () { - const newMutation = this.mutationRoot ? this.mutationRoot.mutationToDom() : null; + const newMutation = this.mutationRoot ? this.mutationRoot.mutationToDom(true) : null; this.props.onRequestClose(newMutation); } handleAddLabel () { diff --git a/src/containers/extension-library.jsx b/src/containers/extension-library.jsx index 5d602cfbd03529da5ec710919f4c98b28e125963..d30daf98a908210e2db25d9a5a8984619c3ff9dd 100644 --- a/src/containers/extension-library.jsx +++ b/src/containers/extension-library.jsx @@ -45,6 +45,8 @@ class ExtensionLibrary extends React.PureComponent { return ( <LibraryComponent data={extensionLibraryThumbnailData} + filterable={false} + id="extensionLibrary" title="Choose an Extension" visible={this.props.visible} onItemSelected={this.handleItemSelect} diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index 22dccba2cb53664e76615766e537ebe6e8eb2a96..73e5f337518da9d50a09d69c11aa3e7603fee475 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -12,6 +12,8 @@ import { SOUNDS_TAB_INDEX } from '../reducers/editor-tab'; +import AppStateHOC from '../lib/app-state-hoc.jsx'; +import ProjectLoaderHOC from '../lib/project-loader-hoc.jsx'; import vmListenerHOC from '../lib/vm-listener-hoc.jsx'; import GUIComponent from '../components/gui/gui.jsx'; @@ -87,7 +89,7 @@ GUI.propTypes = { importInfoVisible: PropTypes.bool, loadingStateVisible: PropTypes.bool, previewInfoVisible: PropTypes.bool, - projectData: PropTypes.string, + projectData: PropTypes.oneOfType([PropTypes.object, PropTypes.string]), vm: PropTypes.instanceOf(VM) }; @@ -115,4 +117,4 @@ const ConnectedGUI = connect( mapDispatchToProps, )(GUI); -export default vmListenerHOC(ConnectedGUI); +export default ProjectLoaderHOC(AppStateHOC(vmListenerHOC(ConnectedGUI))); diff --git a/src/containers/language-selector.jsx b/src/containers/language-selector.jsx index ae0d2405cffb888abd2d8500a93ea382f3b53c5b..316b1577a367a4314ee7a610f72b9bee2516874d 100644 --- a/src/containers/language-selector.jsx +++ b/src/containers/language-selector.jsx @@ -1,5 +1,4 @@ import {connect} from 'react-redux'; -import {updateIntl} from '../reducers/intl.js'; import LanguageSelectorComponent from '../components/language-selector/language-selector.jsx'; @@ -7,10 +6,9 @@ const mapStateToProps = state => ({ currentLocale: state.intl.locale }); -const mapDispatchToProps = dispatch => ({ +const mapDispatchToProps = () => ({ onChange: e => { e.preventDefault(); - dispatch(updateIntl(e.target.value)); } }); diff --git a/src/containers/modal.jsx b/src/containers/modal.jsx new file mode 100644 index 0000000000000000000000000000000000000000..67b14c9ff4494b1e5f354f7befb0b6b0faa9946b --- /dev/null +++ b/src/containers/modal.jsx @@ -0,0 +1,54 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import ModalComponent from '../components/modal/modal.jsx'; + +class Modal extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'addEventListeners', + 'removeEventListeners', + 'handlePopState', + 'pushHistory' + ]); + this.addEventListeners(); + } + componentDidMount () { + // Add a history event only if it's not currently for our modal. This + // avoids polluting the history with many entries. We only need one. + this.pushHistory(this.id, history.state === null); + } + componentWillUnmount () { + this.removeEventListeners(); + } + addEventListeners () { + window.addEventListener('popstate', this.handlePopState); + } + removeEventListeners () { + window.removeEventListener('popstate', this.handlePopState); + } + handlePopState () { + // Whenever someone navigates, we want to be closed + this.props.onRequestClose(); + } + get id () { + return `modal-${this.props.id}`; + } + pushHistory (state, push) { + if (push) return history.pushState(state, this.id); + history.replaceState(state, this.id); + } + render () { + return <ModalComponent {...this.props} />; + } +} + +Modal.propTypes = { + id: PropTypes.string.isRequired, + onRequestClose: PropTypes.func, + onRequestOpen: PropTypes.func +}; + +export default Modal; diff --git a/src/containers/sound-library.jsx b/src/containers/sound-library.jsx index cc333dc78d9e295f6fd59d44dc1abc72a0d4cb43..3d68ea6f32ad19134508c847321147c4f51daf93 100644 --- a/src/containers/sound-library.jsx +++ b/src/containers/sound-library.jsx @@ -1,6 +1,7 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; +import {defineMessages, injectIntl, intlShape} from 'react-intl'; import VM from 'scratch-vm'; import AudioEngine from 'scratch-audio'; @@ -10,6 +11,15 @@ import LibraryComponent from '../components/library/library.jsx'; import soundIcon from '../components/asset-panel/icon--sound.svg'; import soundLibraryContent from '../lib/libraries/sounds.json'; +import soundTags from '../lib/libraries/sound-tags'; + +const messages = defineMessages({ + libraryTitle: { + defaultMessage: 'Choose a Sound', + description: 'Heading for the sound library', + id: 'gui.soundLibrary.chooseASound' + } +}); class SoundLibrary extends React.PureComponent { constructor (props) { @@ -83,7 +93,9 @@ class SoundLibrary extends React.PureComponent { return ( <LibraryComponent data={soundLibraryThumbnailData} - title="Choose a Sound" + id="soundLibrary" + tags={soundTags} + title={this.props.intl.formatMessage(messages.libraryTitle)} onItemMouseEnter={this.handleItemMouseEnter} onItemMouseLeave={this.handleItemMouseLeave} onItemSelected={this.handleItemSelected} @@ -94,9 +106,10 @@ class SoundLibrary extends React.PureComponent { } SoundLibrary.propTypes = { + intl: intlShape.isRequired, onNewSound: PropTypes.func.isRequired, onRequestClose: PropTypes.func, vm: PropTypes.instanceOf(VM).isRequired }; -export default SoundLibrary; +export default injectIntl(SoundLibrary); diff --git a/src/containers/sound-tab.jsx b/src/containers/sound-tab.jsx index 94e72125d15f62333c10de5fee7e704f8a425626..ab4c9062bdebc5fa1b5055919a0df50bc1c3f3a3 100644 --- a/src/containers/sound-tab.jsx +++ b/src/containers/sound-tab.jsx @@ -16,6 +16,7 @@ import SoundEditor from './sound-editor.jsx'; import SoundLibrary from './sound-library.jsx'; import soundLibraryContent from '../lib/libraries/sounds.json'; +import {handleFileUpload, soundUpload} from '../lib/file-uploader.js'; import {connect} from 'react-redux'; @@ -106,41 +107,13 @@ class SoundTab extends React.Component { } handleSoundUpload (e) { - const thisFileInput = e.target; - let thisFile = null; - const reader = new FileReader(); - reader.onload = () => { - // Reset the file input value now that we have everything we need - // so that the user can upload the same sound multiple times if - // they choose - thisFileInput.value = null; - // Cache the sound in storage - const soundBuffer = reader.result; - const storage = this.props.vm.runtime.storage; + const storage = this.props.vm.runtime.storage; + const handleSound = newSound => this.props.vm.addSound(newSound) + .then(() => this.handleNewSound()); - const fileType = thisFile.type; // what file type does the browser think this is - const soundFormat = fileType === 'audio/mp3' ? storage.DataFormat.MP3 : storage.DataFormat.WAV; - const md5 = storage.builtinHelper.cache( - storage.AssetType.Sound, - soundFormat, - new Uint8Array(soundBuffer), - ); - // Add the sound to vm - const newSound = { - format: '', - name: 'sound1', - dataFormat: soundFormat, - md5: `${md5}.${soundFormat}` - }; - - this.props.vm.addSound(newSound).then(() => { - this.handleNewSound(); - }); - }; - if (thisFileInput.files) { - thisFile = thisFileInput.files[0]; - reader.readAsArrayBuffer(thisFile); - } + handleFileUpload(e.target, (buffer, fileType, fileName) => { + soundUpload(buffer, fileType, fileName, storage, handleSound); + }); } setFileInput (input) { diff --git a/src/containers/sprite-library.jsx b/src/containers/sprite-library.jsx index ebfd401af7a7a3c47e3c469bc0be0ceae9a927f9..072e1714a81ca1683acb86e9d280bef47c322156 100644 --- a/src/containers/sprite-library.jsx +++ b/src/containers/sprite-library.jsx @@ -1,13 +1,23 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; +import {injectIntl, intlShape, defineMessages} from 'react-intl'; import VM from 'scratch-vm'; import analytics from '../lib/analytics'; import spriteLibraryContent from '../lib/libraries/sprites.json'; +import spriteTags from '../lib/libraries/sprite-tags'; import LibraryComponent from '../components/library/library.jsx'; +const messages = defineMessages({ + libraryTitle: { + defaultMessage: 'Choose a Sprite', + description: 'Heading for the sprite library', + id: 'gui.spriteLibrary.chooseASprite' + } +}); + class SpriteLibrary extends React.PureComponent { constructor (props) { super(props); @@ -71,7 +81,9 @@ class SpriteLibrary extends React.PureComponent { return ( <LibraryComponent data={this.state.sprites} - title="Choose a Sprite" + id="spriteLibrary" + tags={spriteTags} + title={this.props.intl.formatMessage(messages.libraryTitle)} onItemMouseEnter={this.handleMouseEnter} onItemMouseLeave={this.handleMouseLeave} onItemSelected={this.handleItemSelect} @@ -82,8 +94,9 @@ class SpriteLibrary extends React.PureComponent { } SpriteLibrary.propTypes = { + intl: intlShape.isRequired, onRequestClose: PropTypes.func, vm: PropTypes.instanceOf(VM).isRequired }; -export default SpriteLibrary; +export default injectIntl(SpriteLibrary); diff --git a/src/containers/stage-selector.jsx b/src/containers/stage-selector.jsx index bf749c12a08fa7d997e484aa19b98d252e2a9d30..f3497a54cd9f2e187ffc5a5586b20f7492027f0c 100644 --- a/src/containers/stage-selector.jsx +++ b/src/containers/stage-selector.jsx @@ -10,47 +10,64 @@ import StageSelectorComponent from '../components/stage-selector/stage-selector. import backdropLibraryContent from '../lib/libraries/backdrops.json'; import costumeLibraryContent from '../lib/libraries/costumes.json'; +import {handleFileUpload, costumeUpload} from '../lib/file-uploader.js'; class StageSelector extends React.Component { constructor (props) { super(props); bindAll(this, [ 'handleClick', + 'handleNewBackdrop', 'handleSurpriseBackdrop', 'handleEmptyBackdrop', - 'addBackdropFromLibraryItem' + 'addBackdropFromLibraryItem', + 'handleFileUploadClick', + 'handleBackdropUpload', + 'setFileInput' ]); } addBackdropFromLibraryItem (item) { const vmBackdrop = { name: item.name, + md5: item.md5, 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); + this.handleNewBackdrop(vmBackdrop); } - handleClick (e) { - e.preventDefault(); + handleClick () { this.props.onSelect(this.props.id); } + handleNewBackdrop (backdrop) { + this.props.vm.addBackdrop(backdrop.md5, backdrop).then(() => + this.props.onActivateTab(COSTUMES_TAB_INDEX)); + } handleSurpriseBackdrop () { // @todo should this not add a backdrop you already have? const item = backdropLibraryContent[Math.floor(Math.random() * backdropLibraryContent.length)]; - this.addBackdropFromLibraryItem(item).then(() => { - this.props.onActivateTab(COSTUMES_TAB_INDEX); - }); + 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); - }); + this.addBackdropFromLibraryItem(emptyItem); } } + handleBackdropUpload (e) { + const storage = this.props.vm.runtime.storage; + handleFileUpload(e.target, (buffer, fileType, fileName) => { + costumeUpload(buffer, fileType, fileName, storage, this.handleNewBackdrop); + }); + } + handleFileUploadClick () { + this.fileInput.click(); + } + setFileInput (input) { + this.fileInput = input; + } render () { const { /* eslint-disable no-unused-vars */ @@ -63,9 +80,13 @@ class StageSelector extends React.Component { } = this.props; return ( <StageSelectorComponent + fileInputRef={this.setFileInput} + onBackdropFileUpload={this.handleBackdropUpload} + onBackdropFileUploadClick={this.handleFileUploadClick} onClick={this.handleClick} onEmptyBackdropClick={this.handleEmptyBackdrop} onSurpriseBackdropClick={this.handleSurpriseBackdrop} + {...componentProps} /> ); diff --git a/src/containers/tag-button.jsx b/src/containers/tag-button.jsx new file mode 100644 index 0000000000000000000000000000000000000000..e5ad61f4080abbaced07fe2c6f7ab45bcca7f086 --- /dev/null +++ b/src/containers/tag-button.jsx @@ -0,0 +1,36 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import TagButtonComponent from '../components/tag-button/tag-button.jsx'; + +class TagButton extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleClick' + ]); + } + handleClick () { + this.props.onClick(this.props.title); + } + render () { + return ( + <TagButtonComponent + {...this.props} + onClick={this.handleClick} + /> + ); + } +} + +TagButton.propTypes = { + ...TagButtonComponent.propTypes, + onClick: PropTypes.func, + title: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object + ]).isRequired +}; + +export default TagButton; diff --git a/src/css/units.css b/src/css/units.css index 3e7f73f36d327fa55f464c4b49577661f58b4799..ef34280983f2c54b1fa67c9ecf50e00b141396dc 100644 --- a/src/css/units.css +++ b/src/css/units.css @@ -6,7 +6,8 @@ $menu-bar-height: 3rem; $sprite-info-height: 6rem; $stage-menu-height: 2.75rem; -$library-header-height: 4.375rem; +$library-header-height: 3.125rem; +$library-filter-bar-height: 2.5rem; $form-radius: calc($space / 2); diff --git a/src/lib/app-state-hoc.jsx b/src/lib/app-state-hoc.jsx index 2894cda13e372e2936868b6cd913c9f040f9bb45..e593fa40b304624e01b472695f00ad6546046987 100644 --- a/src/lib/app-state-hoc.jsx +++ b/src/lib/app-state-hoc.jsx @@ -3,7 +3,9 @@ import {Provider} from 'react-redux'; import {createStore, applyMiddleware, compose} from 'redux'; import throttle from 'redux-throttle'; -import {intlInitialState, IntlProvider} from '../reducers/intl.js'; +import {intlShape} from 'react-intl'; +import {IntlProvider, updateIntl} from 'react-intl-redux'; +import {intlInitialState} from '../reducers/intl.js'; import reducer from '../reducers/gui'; const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; @@ -12,25 +14,51 @@ const enhancer = composeEnhancers( throttle(300, {leading: true, trailing: true}) ) ); -const store = createStore(reducer, intlInitialState, enhancer); - -import ErrorBoundary from '../containers/error-boundary.jsx'; /* - * Higher Order Component to provide redux state + * Higher Order Component to provide redux state. If an `intl` prop is provided + * it will override the internal `intl` redux state * @param {React.Component} WrappedComponent - component to provide state for * @returns {React.Component} component with redux and intl state provided */ const AppStateHOC = function (WrappedComponent) { - const AppStateWrapper = ({...props}) => ( - <Provider store={store}> - <IntlProvider> - <ErrorBoundary> - <WrappedComponent {...props} /> - </ErrorBoundary> - </IntlProvider> - </Provider> - ); + class AppStateWrapper extends React.Component { + constructor (props) { + super(props); + let intl = {}; + if (props.intl) { + intl = { + intl: { + defaultLocale: 'en', + locale: props.intl.locale, + messages: props.intl.messages + } + }; + } else { + intl = intlInitialState; + } + + this.store = createStore(reducer, intl, enhancer); + } + componentDidUpdate (prevProps) { + if (prevProps.intl !== this.props.intl) updateIntl(this.props.intl); + } + render () { + return ( + <Provider store={this.store}> + <IntlProvider> + <WrappedComponent {...this.props} /> + </IntlProvider> + </Provider> + ); + + } + + + } + AppStateWrapper.propTypes = { + intl: intlShape + }; return AppStateWrapper; }; diff --git a/src/components/close-button/icon--back.svg b/src/lib/assets/icon--back.svg similarity index 100% rename from src/components/close-button/icon--back.svg rename to src/lib/assets/icon--back.svg diff --git a/src/lib/assets/placeholder.svg b/src/lib/assets/placeholder.svg new file mode 100644 index 0000000000000000000000000000000000000000..67be6428a849d2dccfc43fa657213fc2ac8afbc9 Binary files /dev/null and b/src/lib/assets/placeholder.svg differ diff --git a/src/lib/file-uploader.js b/src/lib/file-uploader.js new file mode 100644 index 0000000000000000000000000000000000000000..cea0aaeb11abc1258672bd0220abd0522ea76b33 --- /dev/null +++ b/src/lib/file-uploader.js @@ -0,0 +1,173 @@ +import {importBitmap} from 'scratch-svg-renderer'; +import log from './log.js'; + +/** + * Extract the file name given a string of the form fileName + ext + * @param {string} nameExt File name + extension (e.g. 'my_image.png') + * @return {string} The name without the extension, or the full name if + * there was no '.' in the string (e.g. 'my_image') + */ +const extractFileName = function (nameExt) { + // There could be multiple dots, but get the stuff before the first . + const nameParts = nameExt.split('.', 1); // we only care about the first . + return nameParts[0]; +}; + +/** + * Handle a file upload given the input element that contains the file, + * and a function to handle loading the file. + * @param {Input} fileInput The <input/> element that contains the file being loaded + * @param {Function} onload The function that handles loading the file + */ +const handleFileUpload = function (fileInput, onload) { + let thisFile = null; + const reader = new FileReader(); + reader.onload = () => { + // Reset the file input value now that we have everything we need + // so that the user can upload the same sound multiple times if + // they choose + fileInput.value = null; + const fileType = thisFile.type; + const fileName = extractFileName(thisFile.name); + + onload(reader.result, fileType, fileName); + }; + if (fileInput.files) { + thisFile = fileInput.files[0]; + reader.readAsArrayBuffer(thisFile); + } +}; + +/** + * @typedef VMAsset + * @property {string} name The user-readable name of this asset - This will + * automatically get translated to a fresh name if this one already exists in the + * scope of this vm asset (e.g. if a sound already exists with the same name for + * the same target) + * @property {string} dataFormat The data format of this asset, typically + * the extension to be used for that particular asset, e.g. 'svg' for vector images + * @property {string} md5 The md5 hash of the asset data, followed by '.'' and dataFormat + */ + +/** + * Cache an asset (costume, sound) in storage and return an object representation + * of the asset to track in the VM. + * @param {ScratchStorage} storage The storage to cache the asset in + * @param {string} fileName The name of the asset + * @param {AssetType} assetType A ScratchStorage AssetType indicating what kind of + * asset this is. + * @param {string} dataFormat The format of this data (typically the file extension) + * @param {UInt8Array} data The asset data buffer + * @return {VMAsset} An object representing this asset and relevant information + * which can be used to look up the data in storage + */ +const cacheAsset = function (storage, fileName, assetType, dataFormat, data) { + const md5 = storage.builtinHelper.cache( + assetType, + dataFormat, + data + ); + + return { + name: fileName, + dataFormat: dataFormat, + md5: `${md5}.${dataFormat}` + }; +}; + +/** + * Handles loading a costume or a backdrop using the provided, context-relevant information. + * @param {ArrayBuffer} fileData The costume data to load + * @param {string} fileType The MIME type of this file + * @param {string} costumeName The user-readable name to use for the costume. + * @param {ScratchStorage} storage The ScratchStorage instance to cache the costume data + * @param {Function} handleCostume The function to execute on the costume object returned after + * caching this costume in storage - This function should be responsible for + * adding the costume to the VM and handling other UI flow that should come after adding the costume + */ +const costumeUpload = function (fileData, fileType, costumeName, storage, handleCostume) { + let costumeFormat = null; + let assetType = null; + switch (fileType) { + case 'image/svg+xml': { + costumeFormat = storage.DataFormat.SVG; + assetType = storage.AssetType.ImageVector; + break; + } + case 'image/jpeg': { + costumeFormat = storage.DataFormat.JPG; + assetType = storage.AssetType.ImageBitmap; + break; + } + case 'image/png': { + costumeFormat = storage.DataFormat.PNG; + assetType = storage.AssetType.ImageBitmap; + break; + } + default: + return; + } + + const addCostumeFromBuffer = function (error, costumeBuffer) { + if (error) { + log.warn(`An error occurred while trying to extract image data: ${error}`); + return; + } + + const vmCostume = cacheAsset(storage, costumeName, assetType, costumeFormat, costumeBuffer); + handleCostume(vmCostume); + }; + + if (costumeFormat === storage.DataFormat.SVG) { + // Must pass in file data as a Uint8Array, + // passing in an array buffer causes the sprite/costume + // thumbnails to not display because the data URI for the costume + // is invalid + addCostumeFromBuffer(null, new Uint8Array(fileData)); + } else { + // otherwise it's a bitmap + importBitmap(fileData, addCostumeFromBuffer); + } +}; + +/** + * Handles loading a sound using the provided, context-relevant information. + * @param {ArrayBuffer} fileData The sound data to load + * @param {string} fileType The MIME type of this file; This function will exit + * early if the fileType is unexpected. + * @param {string} soundName The user-readable name to use for the sound. + * @param {ScratchStorage} storage The ScratchStorage instance to cache the sound data + * @param {Function} handleSound The function to execute on the sound object of type VMAsset + * This function should be responsible for adding the sound to the VM + * as well as handling other UI flow that should come after adding the sound + */ +const soundUpload = function (fileData, fileType, soundName, storage, handleSound) { + let soundFormat; + switch (fileType) { + case 'audio/mp3': { + soundFormat = storage.DataFormat.MP3; + break; + } + case 'audio/wav': { // TODO support audio/x-wav? Do we see this in the wild? + soundFormat = storage.DataFormat.WAV; + break; + } + default: + return; + } + + const vmSound = cacheAsset( + storage, + soundName, + storage.AssetType.Sound, + soundFormat, + new Uint8Array(fileData)); + + handleSound(vmSound); +}; + +export { + handleFileUpload, + costumeUpload, + soundUpload +}; diff --git a/src/lib/libraries/backdrop-tags.js b/src/lib/libraries/backdrop-tags.js new file mode 100644 index 0000000000000000000000000000000000000000..7bafa3410aa3afe94692d0e4d495a9f8ecaeb256 --- /dev/null +++ b/src/lib/libraries/backdrop-tags.js @@ -0,0 +1,10 @@ +export default [ + {title: 'Fantasy'}, + {title: 'Music'}, + {title: 'Sports'}, + {title: 'Outdoors'}, + {title: 'Indoors'}, + {title: 'Space'}, + {title: 'Underwater'}, + {title: 'Patterns'} +]; diff --git a/src/lib/libraries/extensions/index.js b/src/lib/libraries/extensions/index.js index 29b3b8012948e62d37e4ef24ea3e8bb5123db149..670df71eef4a847c3b4462f037056506fb30661e 100644 --- a/src/lib/libraries/extensions/index.js +++ b/src/lib/libraries/extensions/index.js @@ -54,7 +54,7 @@ export default [ disabled: true }, { - name: 'LEGO Mindstorms EV3', + name: 'LEGO MINDSTORMS EV3', extensionURL: '', iconURL: ev3Image, description: 'Build interactive robots and more.', diff --git a/src/lib/libraries/sound-tags.js b/src/lib/libraries/sound-tags.js new file mode 100644 index 0000000000000000000000000000000000000000..c35122797b0a01e0eac827db19444fe07d194d0a --- /dev/null +++ b/src/lib/libraries/sound-tags.js @@ -0,0 +1,11 @@ +export default [ + {title: 'Animal'}, + {title: 'Effects'}, + {title: 'Loops'}, + {title: 'Notes'}, + {title: 'Percussion'}, + {title: 'Space'}, + {title: 'Sports'}, + {title: 'Voice'}, + {title: 'Wacky'} +]; diff --git a/src/lib/libraries/sprite-tags.js b/src/lib/libraries/sprite-tags.js new file mode 100644 index 0000000000000000000000000000000000000000..f1fbe3d5647910980f1133dd57aa05569ee05334 --- /dev/null +++ b/src/lib/libraries/sprite-tags.js @@ -0,0 +1,10 @@ +export default [ + {title: 'Animals'}, + {title: 'People'}, + {title: 'Fantasy'}, + {title: 'Dance'}, + {title: 'Music'}, + {title: 'Sports'}, + {title: 'Food'}, + {title: 'Fashion'} +]; diff --git a/src/lib/libraries/sprites.json b/src/lib/libraries/sprites.json index f0aa1bab7f75e552068b29ec4d787712deff0001..7a08b0033a1076e03b8b922659bfb8f0e49d0ddb 100644 --- a/src/lib/libraries/sprites.json +++ b/src/lib/libraries/sprites.json @@ -3,7 +3,7 @@ "name": "Abby", "md5": "afab2d2141e9811bd89e385e9628cb5f.svg", "type": "sprite", - "tags": [], + "tags": ["people"], "info": [ 0, 4, @@ -71,7 +71,7 @@ "name": "Apple", "md5": "831ccd4741a7a56d85f6698a21f4ca69.svg", "type": "sprite", - "tags": [], + "tags": ["food"], "info": [ 0, 1, @@ -183,7 +183,7 @@ "name": "Ball", "md5": "10117ddaefa98d819f2b1df93805622f.svg", "type": "sprite", - "tags": [], + "tags": ["sports"], "info": [ 0, 5, @@ -259,7 +259,7 @@ "name": "Balloon1", "md5": "bc96a1fb5fe794377acd44807e421ce2.svg", "type": "sprite", - "tags": [], + "tags": ["fantasy"], "info": [ 0, 3, @@ -319,7 +319,7 @@ "name": "Baseball", "md5": "6e13ef53885d2cfca9a54cc5c3f6c20f.svg", "type": "sprite", - "tags": [], + "tags": ["sports"], "info": [ 0, 1, @@ -363,7 +363,7 @@ "name": "Basketball", "md5": "b15c425f3eef68e7d095ee91321cb52a.svg", "type": "sprite", - "tags": [], + "tags": ["sports"], "info": [ 0, 1, @@ -407,7 +407,7 @@ "name": "Bat1", "md5": "7ad915f8e0884f497a24d5bb61ea8a4a.svg", "type": "sprite", - "tags": [], + "tags": ["animals", "fantasy"], "info": [ 0, 2, @@ -459,7 +459,7 @@ "name": "Bat2", "md5": "647d4bd53163f94a7dabf623ccab7fd3.svg", "type": "sprite", - "tags": [], + "tags": ["animals", "fantasy"], "info": [ 0, 2, @@ -511,7 +511,7 @@ "name": "Beachball", "md5": "87d64cab74c64b31498cc85f07510ee4.svg", "type": "sprite", - "tags": [], + "tags": ["sports"], "info": [ 0, 1, @@ -555,7 +555,7 @@ "name": "Beetle", "md5": "e1ce8f153f011fdd52486c91c6ed594d.svg", "type": "sprite", - "tags": [], + "tags": ["animals"], "info": [ 0, 1, @@ -599,7 +599,7 @@ "name": "Bell", "md5": "f35056c772395455d703773657e1da6e.svg", "type": "sprite", - "tags": [], + "tags": ["music"], "info": [ 0, 1, @@ -767,7 +767,7 @@ "name": "Butterfly1", "md5": "836d4cc7889f4a1cbcb0303934f31f79.svg", "type": "sprite", - "tags": [], + "tags": ["animals"], "info": [ 0, 2, @@ -819,7 +819,7 @@ "name": "Butterfly2", "md5": "a0216895beade6afc6d42bd5bb43faea.svg", "type": "sprite", - "tags": [], + "tags": ["animals", "fantasy"], "info": [ 0, 1, @@ -1871,7 +1871,7 @@ "name": "Dog1", "md5": "39ddefa0cc58f3b1b06474d63d81ef56.svg", "type": "sprite", - "tags": [], + "tags": ["animals", "puppy"], "info": [ 0, 2, @@ -1983,7 +1983,7 @@ "name": "Donut", "md5": "9e7b4d153421dae04a24571d7e079e85.svg", "type": "sprite", - "tags": [], + "tags": ["food"], "info": [ 0, 1, @@ -2891,7 +2891,7 @@ "name": "Ghost1", "md5": "c88579c578f2d171de78612f2ff9c9d9.svg", "type": "sprite", - "tags": [], + "tags": ["fantasy"], "info": [ 0, 1, @@ -2935,7 +2935,7 @@ "name": "Ghost2", "md5": "607be245da950af1a4e4d79acfda46e3.svg", "type": "sprite", - "tags": [], + "tags": ["fantasy"], "info": [ 0, 2, @@ -3191,7 +3191,7 @@ "name": "Guitar", "md5": "cb8c2a5e69da7538e1dd73cb7ff4a666.svg", "type": "sprite", - "tags": [], + "tags": ["music"], "info": [ 0, 2, @@ -3299,7 +3299,7 @@ "name": "Guitar-electric2", "md5": "1fc433b89038f9e16092c9f4d7514cca.svg", "type": "sprite", - "tags": [], + "tags": ["music"], "info": [ 0, 2, @@ -4483,7 +4483,7 @@ "name": "Milk", "md5": "e6a7964bc4ea38c79a5a31d6ddfb5ba9.svg", "type": "sprite", - "tags": [], + "tags": ["food"], "info": [ 0, 5, @@ -5343,7 +5343,7 @@ "name": "Rainbow", "md5": "680a806bd87a28c8b25b5f9b6347f022.svg", "type": "sprite", - "tags": [], + "tags": ["fantasy"], "info": [ 0, 1, @@ -5615,7 +5615,7 @@ "name": "Sam", "md5": "7f32d8d78ad64f50c018b7b578de2e18.svg", "type": "sprite", - "tags": [], + "tags": ["people", "sports"], "info": [ 0, 4, @@ -6623,7 +6623,7 @@ "name": "Tera", "md5": "b54a4a9087435863ab6f6c908f1cac99.svg", "type": "sprite", - "tags": [], + "tags": ["fantasy", "dance"], "info": [ 0, 4, @@ -7007,7 +7007,7 @@ "name": "Wizard", "md5": "4df1dd733f6ee4a2d8842478ac2c4661.svg", "type": "sprite", - "tags": [], + "tags": ["fantasy"], "info": [ 0, 1, @@ -7051,7 +7051,7 @@ "name": "Wizard1", "md5": "e085c97691d16f0cc8a595ce1137e26c.svg", "type": "sprite", - "tags": [], + "tags": ["fantasy"], "info": [ 0, 1, @@ -7095,7 +7095,7 @@ "name": "Wizard2", "md5": "6db4d9e4229dc50a1b1c91c3c8311d40.svg", "type": "sprite", - "tags": [], + "tags": ["fantasy"], "info": [ 0, 1, @@ -11447,4 +11447,4 @@ "spriteInfo": {} } } -] \ No newline at end of file +] diff --git a/src/lib/make-toolbox-xml.js b/src/lib/make-toolbox-xml.js index 10ff86ea6d48b82ade58bb0fce3e6a60b17a5498..9ecb6353ad5dd6ad6c02f1f4774f5eb8a344536d 100644 --- a/src/lib/make-toolbox-xml.js +++ b/src/lib/make-toolbox-xml.js @@ -189,7 +189,7 @@ const looks = function (isStage, targetId) { </block> <block type="looks_nextbackdrop"/> ` : ` - <block type="looks_switchcostumeto"> + <block id="${targetId}_switchcostumeto" type="looks_switchcostumeto"> <value name="COSTUME"> <shadow type="looks_costume"/> </value> @@ -259,15 +259,15 @@ const looks = function (isStage, targetId) { `; }; -const sound = function () { +const sound = function (isStage, targetId) { return ` <category name="Sound" colour="#D65CD6" secondaryColour="#BD42BD"> - <block type="sound_play"> + <block id="${targetId}_sound_play" type="sound_play"> <value name="SOUND_MENU"> <shadow type="sound_sounds_menu"/> </value> </block> - <block type="sound_playuntildone"> + <block id="${targetId}_sound_playuntildone" type="sound_playuntildone"> <value name="SOUND_MENU"> <shadow type="sound_sounds_menu"/> </value> @@ -531,7 +531,7 @@ const operators = function () { </value> </block> ${blockSeparator} - <block type="operator_lt"> + <block type="operator_gt"> <value name="OPERAND1"> <shadow type="text"> <field name="TEXT"/> @@ -539,11 +539,11 @@ const operators = function () { </value> <value name="OPERAND2"> <shadow type="text"> - <field name="TEXT"/> + <field name="TEXT">100</field> </shadow> </value> </block> - <block type="operator_equals"> + <block type="operator_lt"> <value name="OPERAND1"> <shadow type="text"> <field name="TEXT"/> @@ -551,11 +551,11 @@ const operators = function () { </value> <value name="OPERAND2"> <shadow type="text"> - <field name="TEXT"/> + <field name="TEXT">100</field> </shadow> </value> </block> - <block type="operator_gt"> + <block type="operator_equals"> <value name="OPERAND1"> <shadow type="text"> <field name="TEXT"/> @@ -563,7 +563,7 @@ const operators = function () { </value> <value name="OPERAND2"> <shadow type="text"> - <field name="TEXT"/> + <field name="TEXT">100</field> </shadow> </value> </block> @@ -575,12 +575,12 @@ const operators = function () { <block type="operator_join"> <value name="STRING1"> <shadow type="text"> - <field name="TEXT">hello</field> + <field name="TEXT">apple</field> </shadow> </value> <value name="STRING2"> <shadow type="text"> - <field name="TEXT">world</field> + <field name="TEXT">banana</field> </shadow> </value> </block> @@ -592,26 +592,26 @@ const operators = function () { </value> <value name="STRING"> <shadow type="text"> - <field name="TEXT">world</field> + <field name="TEXT">apple</field> </shadow> </value> </block> <block type="operator_length"> <value name="STRING"> <shadow type="text"> - <field name="TEXT">world</field> + <field name="TEXT">apple</field> </shadow> </value> </block> <block type="operator_contains" id="operator_contains"> <value name="STRING1"> <shadow type="text"> - <field name="TEXT">hello</field> + <field name="TEXT">apple</field> </shadow> </value> <value name="STRING2"> <shadow type="text"> - <field name="TEXT">world</field> + <field name="TEXT">a</field> </shadow> </value> </block> diff --git a/src/lib/project-loader-hoc.jsx b/src/lib/project-loader-hoc.jsx index 5ccb382b2560f27756bb04276ab7eaff3440e8a8..cad9367802759f35cec601e4eb89a4ede989503b 100644 --- a/src/lib/project-loader-hoc.jsx +++ b/src/lib/project-loader-hoc.jsx @@ -1,11 +1,13 @@ import React from 'react'; +import PropTypes from 'prop-types'; import analytics from './analytics'; import log from './log'; import storage from './storage'; /* Higher Order Component to provide behavior for loading projects by id from - * the window's hash (#this part in the url) + * the window's hash (#this part in the url) or by projectId prop passed in from + * the parent (i.e. scratch-www) * @param {React.Component} WrappedComponent component to receive projectData prop * @returns {React.Component} component with project loading behavior */ @@ -45,7 +47,7 @@ const ProjectLoaderHOC = function (WrappedComponent) { return window.location.hash.substring(1); } updateProject () { - let projectId = this.fetchProjectId(); + let projectId = this.props.projectId || this.fetchProjectId(); if (projectId !== this.state.projectId) { if (projectId.length < 1) projectId = 0; this.setState({projectId: projectId}); @@ -61,21 +63,27 @@ const ProjectLoaderHOC = function (WrappedComponent) { } } render () { + const { + projectId, // eslint-disable-line no-unused-vars + ...componentProps + } = this.props; if (!this.state.projectData) return null; return ( <WrappedComponent fetchingProject={this.state.fetchingProject} projectData={this.state.projectData} - {...this.props} + {...componentProps} /> ); } } + ProjectLoaderComponent.propTypes = { + projectId: PropTypes.string + }; return ProjectLoaderComponent; }; - export { ProjectLoaderHOC as default }; diff --git a/src/examples/blocks-only.css b/src/playground/blocks-only.css similarity index 100% rename from src/examples/blocks-only.css rename to src/playground/blocks-only.css diff --git a/src/examples/blocks-only.jsx b/src/playground/blocks-only.jsx similarity index 89% rename from src/examples/blocks-only.jsx rename to src/playground/blocks-only.jsx index 8d5159e825105374827e818ce7009e57587e87de..af330d39d81c4de0a59e82c26a53275aa6e4b370 100644 --- a/src/examples/blocks-only.jsx +++ b/src/playground/blocks-only.jsx @@ -2,7 +2,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; import {connect} from 'react-redux'; -import AppStateHOC from '../lib/app-state-hoc.jsx'; import Controls from '../containers/controls.jsx'; import Blocks from '../containers/blocks.jsx'; import GUI from '../containers/gui.jsx'; @@ -27,7 +26,7 @@ const BlocksOnly = props => ( </GUI> ); -const App = AppStateHOC(ProjectLoaderHOC(BlocksOnly)); +const App = ProjectLoaderHOC(BlocksOnly); const appTarget = document.createElement('div'); document.body.appendChild(appTarget); diff --git a/src/examples/compatibility-testing.jsx b/src/playground/compatibility-testing.jsx similarity index 95% rename from src/examples/compatibility-testing.jsx rename to src/playground/compatibility-testing.jsx index 3fa8e71d94e34e4722ec1a1f329c9df46de2688c..b55a34c4ea6b0b70563a9f189284ae82f0019d30 100644 --- a/src/examples/compatibility-testing.jsx +++ b/src/playground/compatibility-testing.jsx @@ -2,7 +2,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; import {connect} from 'react-redux'; -import AppStateHOC from '../lib/app-state-hoc.jsx'; import Controls from '../containers/controls.jsx'; import Stage from '../containers/stage.jsx'; import Box from '../components/box/box.jsx'; @@ -72,7 +71,7 @@ class Player extends React.Component { } } -const App = AppStateHOC(ProjectLoaderHOC(Player)); +const App = ProjectLoaderHOC(Player); const appTarget = document.createElement('div'); document.body.appendChild(appTarget); diff --git a/src/playground/error-boundary-hoc.jsx b/src/playground/error-boundary-hoc.jsx new file mode 100644 index 0000000000000000000000000000000000000000..9132429851810454c402b9dc40d146201edcede2 --- /dev/null +++ b/src/playground/error-boundary-hoc.jsx @@ -0,0 +1,18 @@ +import React from 'react'; +import ErrorBoundary from '../containers/error-boundary.jsx'; + +/* + * Higher Order Component to provide error boundary for wrapped component + * @param {React.Component} WrappedComponent - component to provide state for + * @returns {React.Component} component with error boundary + */ +const ErrorBoundaryHOC = function (WrappedComponent) { + const ErrorBoundaryWrapper = props => ( + <ErrorBoundary> + <WrappedComponent {...props} /> + </ErrorBoundary> + ); + return ErrorBoundaryWrapper; +}; + +export default ErrorBoundaryHOC; diff --git a/src/index.css b/src/playground/index.css similarity index 100% rename from src/index.css rename to src/playground/index.css diff --git a/src/index.ejs b/src/playground/index.ejs similarity index 100% rename from src/index.ejs rename to src/playground/index.ejs diff --git a/src/index.jsx b/src/playground/index.jsx similarity index 70% rename from src/index.jsx rename to src/playground/index.jsx index 114c45ce0ad6194d76270bee6e79bea378ecb9ec..02ea975533d750449bd8c87c89e4ff96352ba5b6 100644 --- a/src/index.jsx +++ b/src/playground/index.jsx @@ -3,10 +3,9 @@ import React from 'react'; import ReactDOM from 'react-dom'; import Modal from 'react-modal'; -import analytics from './lib/analytics'; -import AppStateHOC from './lib/app-state-hoc.jsx'; -import GUI from './containers/gui.jsx'; -import ProjectLoaderHOC from './lib/project-loader-hoc.jsx'; +import analytics from '../lib/analytics'; +import GUI from '../containers/gui.jsx'; +import ErrorBoundaryHOC from './error-boundary-hoc.jsx'; import styles from './index.css'; @@ -18,7 +17,7 @@ if (process.env.NODE_ENV === 'production' && typeof window === 'object') { // Register "base" page view analytics.pageview('/'); -const App = AppStateHOC(ProjectLoaderHOC(GUI)); +const App = ErrorBoundaryHOC(GUI); const appTarget = document.createElement('div'); appTarget.className = styles.app; diff --git a/src/playground/intl.js b/src/playground/intl.js new file mode 100644 index 0000000000000000000000000000000000000000..bc427c402a022d03fa3bd64b0a6ffd6ff5b5afbf --- /dev/null +++ b/src/playground/intl.js @@ -0,0 +1,25 @@ +import {addLocaleData} from 'react-intl'; +import defaultsDeep from 'lodash.defaultsdeep'; + +import localeData from 'scratch-l10n'; +import guiMessages from 'scratch-l10n/locales/gui-msgs'; +import paintMessages from 'scratch-l10n/locales/paint-msgs'; +import penMessages from 'scratch-l10n/locales/pen-msgs'; + +const combinedMessages = defaultsDeep({}, guiMessages.messages, paintMessages.messages, penMessages.messages); + +Object.keys(localeData).forEach(locale => { + // TODO: will need to handle locales not in the default intl - see www/custom-locales + addLocaleData(localeData[locale].localeData); +}); + +const intlDefault = { + defaultLocale: 'en', + locale: 'en', + messages: combinedMessages.en.messages +}; + +export { + intlDefault as default, + combinedMessages +}; diff --git a/src/examples/player.css b/src/playground/player.css similarity index 100% rename from src/examples/player.css rename to src/playground/player.css diff --git a/src/examples/player.jsx b/src/playground/player.jsx similarity index 95% rename from src/examples/player.jsx rename to src/playground/player.jsx index ab50527cde1ae1d4be7788e7545397ff48b3700c..58bc0a6a73c7b65e5346fb4aa5eaddf63ce74e4d 100644 --- a/src/examples/player.jsx +++ b/src/playground/player.jsx @@ -2,7 +2,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; import {connect} from 'react-redux'; -import AppStateHOC from '../lib/app-state-hoc.jsx'; import Controls from '../containers/controls.jsx'; import Stage from '../containers/stage.jsx'; import Box from '../components/box/box.jsx'; @@ -69,7 +68,7 @@ class Player extends React.Component { } } -const App = AppStateHOC(ProjectLoaderHOC(Player)); +const App = ProjectLoaderHOC(Player); const appTarget = document.createElement('div'); document.body.appendChild(appTarget); diff --git a/src/reducers/intl.js b/src/reducers/intl.js index 15a2d307b885917cf75e363bf9b484d412edd2fb..8c49ee10475c5a960c35d6eba723203dbb772091 100644 --- a/src/reducers/intl.js +++ b/src/reducers/intl.js @@ -1,36 +1,14 @@ -import {addLocaleData} from 'react-intl'; -import {updateIntl as superUpdateIntl} from 'react-intl-redux'; -import {IntlProvider, intlReducer} from 'react-intl-redux'; -import defaultsDeep from 'lodash.defaultsdeep'; - -import localeData from 'scratch-l10n'; -import guiMessages from 'scratch-l10n/locales/gui-msgs'; -import paintMessages from 'scratch-l10n/locales/paint-msgs'; -import penMessages from 'scratch-l10n/locales/pen-msgs'; - -const combinedMessages = defaultsDeep({}, guiMessages.messages, paintMessages.messages, penMessages.messages); - -Object.keys(localeData).forEach(locale => { - // TODO: will need to handle locales not in the default intl - see www/custom-locales - addLocaleData(localeData[locale].localeData); -}); +import {intlReducer} from 'react-intl-redux'; const intlInitialState = { intl: { defaultLocale: 'en', locale: 'en', - messages: combinedMessages.en.messages + messages: {} } }; -const updateIntl = locale => superUpdateIntl({ - locale: locale, - messages: combinedMessages[locale].messages || combinedMessages.en.messages -}); - export { intlReducer as default, - IntlProvider, - intlInitialState, - updateIntl + intlInitialState }; diff --git a/test/helpers/selenium-helper.js b/test/helpers/selenium-helper.js index b18f356518c56c8154bc63b0acc9d856507e15f1..f88959e5478c9cdaf94bb8d5e32fccb5c4907294 100644 --- a/test/helpers/selenium-helper.js +++ b/test/helpers/selenium-helper.js @@ -6,6 +6,8 @@ import webdriver from 'selenium-webdriver'; const {By, until, Button} = webdriver; +const USE_HEADLESS = process.env.USE_HEADLESS !== 'no'; + class SeleniumHelper { constructor () { bindAll(this, [ @@ -35,7 +37,11 @@ class SeleniumHelper { getDriver () { const chromeCapabilities = webdriver.Capabilities.chrome(); - chromeCapabilities.set('chromeOptions', {args: ['--headless']}); + const args = []; + if (USE_HEADLESS) { + args.push('--headless'); + } + chromeCapabilities.set('chromeOptions', {args}); this.driver = new webdriver.Builder() .forBrowser('chrome') .withCapabilities(chromeCapabilities) @@ -98,12 +104,8 @@ class SeleniumHelper { const message = entry.message; for (let i = 0; i < whitelist.length; i++) { if (message.indexOf(whitelist[i]) !== -1) { - // eslint-disable-next-line no-console - console.warn(`Ignoring whitelisted error: ${whitelist[i]}`); return false; } else if (entry.level !== 'SEVERE') { - // eslint-disable-next-line no-console - console.warn(`Ignoring non-SEVERE entry: ${message}`); return false; } } diff --git a/test/integration/blocks.test.js b/test/integration/blocks.test.js index 4f3411d5574521f651fbaad4b17ca553535629de..5fba5cfe4de9b26a1b32ae11bcfa58036c92883b 100644 --- a/test/integration/blocks.test.js +++ b/test/integration/blocks.test.js @@ -33,7 +33,7 @@ describe('Working with the blocks', () => { await clickText('Operators', scope.blocksTab); await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation await clickText('join', scope.blocksTab); // Click "join <hello> <world>" block - await findByText('helloworld', scope.reportedValue); // Tooltip with result + await findByText('applebanana', scope.reportedValue); // Tooltip with result const logs = await getLogs(); await expect(logs).toEqual([]); }); diff --git a/test/integration/costumes.test.js b/test/integration/costumes.test.js index bec4647e75b17b4c9535eb534d7fb6535adea585..bb4ee08c4b1958a98aaac1595742a3e3fe0d261c 100644 --- a/test/integration/costumes.test.js +++ b/test/integration/costumes.test.js @@ -30,7 +30,7 @@ describe('Working with costumes', () => { await clickXpath('//button[@title="tryit"]'); await clickText('Costumes'); await clickXpath('//button[@aria-label="Choose a Costume"]'); - const el = await findByXpath("//input[@placeholder='what are you looking for?']"); + const el = await findByXpath("//input[@placeholder='Search']"); await el.sendKeys('abb'); await clickText('Abby-a'); // Should close the modal, then click the costumes in the selector await findByXpath("//input[@value='Abby-a']"); // Should show editor for new costume @@ -58,7 +58,7 @@ describe('Working with costumes', () => { await loadUri(uri); await clickXpath('//button[@title="tryit"]'); await clickXpath('//button[@aria-label="Choose a Backdrop"]'); - const el = await findByXpath("//input[@placeholder='what are you looking for?']"); + const el = await findByXpath("//input[@placeholder='Search']"); await el.sendKeys('blue'); await clickText('Blue Sky'); // Should close the modal const logs = await getLogs(); diff --git a/test/integration/project-loading.test.js b/test/integration/project-loading.test.js index 6add67c42b178055b653bf94cf9a29c41a5db9cc..cb0477ec9317a9fbb2fc0ca91c3e564b1b525ad2 100644 --- a/test/integration/project-loading.test.js +++ b/test/integration/project-loading.test.js @@ -66,6 +66,9 @@ describe('Loading scratch gui', () => { }); test('Load a project by ID directly through url', async () => { + await driver.quit(); // Reset driver to test hitting # url directly + driver = getDriver(); + const projectId = '96708228'; await loadUri(`${uri}#${projectId}`); await clickXpath('//button[@title="tryit"]'); @@ -78,6 +81,9 @@ describe('Loading scratch gui', () => { }); test('Load a project by ID (fullscreen)', async () => { + await driver.quit(); // Reset driver to test hitting # url directly + driver = getDriver(); + const prevSize = driver.manage() .window() .getSize(); diff --git a/test/integration/sounds.test.js b/test/integration/sounds.test.js index 2cbff5d84bc7a6f9314eeab0950d5fb202ed2781..44b670dd72c51ccb74a7dc6477541df21bbb70c4 100644 --- a/test/integration/sounds.test.js +++ b/test/integration/sounds.test.js @@ -38,13 +38,13 @@ describe('Working with sounds', () => { // Add it back await clickXpath('//button[@aria-label="Choose a Sound"]'); - let el = await findByXpath("//input[@placeholder='what are you looking for?']"); + let el = await findByXpath("//input[@placeholder='Search']"); await el.sendKeys('meow'); await clickText('Meow', scope.modal); // Should close the modal // Add a new sound await clickXpath('//button[@aria-label="Choose a Sound"]'); - el = await findByXpath("//input[@placeholder='what are you looking for?']"); + el = await findByXpath("//input[@placeholder='Search']"); await el.sendKeys('chom'); await clickText('Chomp'); // Should close the modal, then click the sounds in the selector await findByXpath("//input[@value='Chomp']"); // Should show editor for new sound diff --git a/webpack.config.js b/webpack.config.js index 389cf8abd6db12e1deb277d680469a688d518cb6..393e9dbb44ffe9b25971ad3d7195dd319ad6e977 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -1,3 +1,4 @@ +const defaultsDeep = require('lodash.defaultsdeep'); var path = require('path'); var webpack = require('webpack'); @@ -10,22 +11,15 @@ var autoprefixer = require('autoprefixer'); var postcssVars = require('postcss-simple-vars'); var postcssImport = require('postcss-import'); -module.exports = { +const base = { + devtool: 'cheap-module-source-map', devServer: { contentBase: path.resolve(__dirname, 'build'), host: '0.0.0.0', port: process.env.PORT || 8601 }, - devtool: 'cheap-module-source-map', - entry: { - lib: ['react', 'react-dom'], - gui: './src/index.jsx', - blocksonly: './src/examples/blocks-only.jsx', - compatibilitytesting: './src/examples/compatibility-testing.jsx', - player: './src/examples/player.jsx' - }, output: { - path: path.resolve(__dirname, 'build'), + library: 'GUI', filename: '[name].js' }, externals: { @@ -65,65 +59,133 @@ module.exports = { } } }] - }, - { - test: /\.(svg|png|wav)$/, - loader: 'file-loader' }] }, - plugins: [ - new webpack.DefinePlugin({ - 'process.env.NODE_ENV': '"' + process.env.NODE_ENV + '"', - 'process.env.DEBUG': Boolean(process.env.DEBUG) - }), - new webpack.optimize.CommonsChunkPlugin({ - name: 'lib', - filename: 'lib.min.js' - }), - new HtmlWebpackPlugin({ - chunks: ['lib', 'gui'], - template: 'src/index.ejs', - title: 'Scratch 3.0 GUI' - }), - new HtmlWebpackPlugin({ - chunks: ['lib', 'blocksonly'], - template: 'src/index.ejs', - filename: 'blocks-only.html', - title: 'Scratch 3.0 GUI: Blocks Only Example' - }), - new HtmlWebpackPlugin({ - chunks: ['lib', 'compatibilitytesting'], - template: 'src/index.ejs', - filename: 'compatibility-testing.html', - title: 'Scratch 3.0 GUI: Compatibility Testing' - }), - new HtmlWebpackPlugin({ - chunks: ['lib', 'player'], - template: 'src/index.ejs', - filename: 'player.html', - title: 'Scratch 3.0 GUI: Player Example' - }), - new CopyWebpackPlugin([{ - from: 'static', - to: 'static' - }]), - new CopyWebpackPlugin([{ - from: 'node_modules/scratch-blocks/media', - to: 'static/blocks-media' - }]), - new CopyWebpackPlugin([{ - from: 'extensions/**', - to: 'static', - context: 'src/examples' - }]), - new CopyWebpackPlugin([{ - from: 'extension-worker.{js,js.map}', - context: 'node_modules/scratch-vm/dist/web' - }]) - ].concat(process.env.NODE_ENV === 'production' ? [ + plugins: [].concat(process.env.NODE_ENV === 'production' ? [ new webpack.optimize.UglifyJsPlugin({ include: /\.min\.js$/, minimize: true }) ] : []) }; + +module.exports = [ + // to run editor examples + defaultsDeep({}, base, { + entry: { + lib: ['react', 'react-dom'], + gui: './src/playground/index.jsx', + blocksonly: './src/playground/blocks-only.jsx', + compatibilitytesting: './src/playground/compatibility-testing.jsx', + player: './src/playground/player.jsx' + }, + output: { + path: path.resolve(__dirname, 'build'), + filename: '[name].js' + }, + externals: { + React: 'react', + ReactDOM: 'react-dom' + }, + module: { + rules: base.module.rules.concat([ + { + test: /\.(svg|png|wav)$/, + loader: 'file-loader', + options: { + outputPath: 'static/assets/' + } + } + ]) + }, + plugins: base.plugins.concat([ + new webpack.DefinePlugin({ + 'process.env.NODE_ENV': '"' + process.env.NODE_ENV + '"', + 'process.env.DEBUG': Boolean(process.env.DEBUG) + }), + new webpack.optimize.CommonsChunkPlugin({ + name: 'lib', + filename: 'lib.min.js' + }), + new HtmlWebpackPlugin({ + chunks: ['lib', 'gui'], + template: 'src/playground/index.ejs', + title: 'Scratch 3.0 GUI' + }), + new HtmlWebpackPlugin({ + chunks: ['lib', 'blocksonly'], + template: 'src/playground/index.ejs', + filename: 'blocks-only.html', + title: 'Scratch 3.0 GUI: Blocks Only Example' + }), + new HtmlWebpackPlugin({ + chunks: ['lib', 'compatibilitytesting'], + template: 'src/playground/index.ejs', + filename: 'compatibility-testing.html', + title: 'Scratch 3.0 GUI: Compatibility Testing' + }), + new HtmlWebpackPlugin({ + chunks: ['lib', 'player'], + template: 'src/playground/index.ejs', + filename: 'player.html', + title: 'Scratch 3.0 GUI: Player Example' + }), + new CopyWebpackPlugin([{ + from: 'static', + to: 'static' + }]), + new CopyWebpackPlugin([{ + from: 'node_modules/scratch-blocks/media', + to: 'static/blocks-media' + }]), + new CopyWebpackPlugin([{ + from: 'extensions/**', + to: 'static', + context: 'src/examples' + }]), + new CopyWebpackPlugin([{ + from: 'extension-worker.{js,js.map}', + context: 'node_modules/scratch-vm/dist/web' + }]) + ]) + }) +].concat( + process.env.NODE_ENV === 'production' ? ( + // export as library + defaultsDeep({}, base, { + target: 'web', + entry: { + 'scratch-gui': './src/containers/gui.jsx' + }, + output: { + libraryTarget: 'umd', + path: path.resolve('dist') + }, + externals: { + React: 'react', + ReactDOM: 'react-dom' + }, + module: { + rules: base.module.rules.concat([ + { + test: /\.(svg|png|wav)$/, + loader: 'file-loader', + options: { + outputPath: 'static/assets/', + publicPath: '/static/assets/' + } + } + ]) + }, + plugins: base.plugins.concat([ + new CopyWebpackPlugin([{ + from: 'node_modules/scratch-blocks/media', + to: 'static/blocks-media' + }]), + new CopyWebpackPlugin([{ + from: 'extension-worker.{js,js.map}', + context: 'node_modules/scratch-vm/dist/web' + }]) + ]) + })) : [] +);