diff --git a/.travis.yml b/.travis.yml index 33c4de439700bd708bdd20c6e783e63d97992dc0..13ccbc30b19e7a92ed2c25a39bf0f5037daa651a 100644 --- a/.travis.yml +++ b/.travis.yml @@ -11,12 +11,9 @@ node_js: cache: directories: - node_modules -env: - global: - - NODE_ENV=production install: -- npm --production=false install -- npm --production=false update +- npm install +- npm 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) @@ -39,4 +36,3 @@ deploy: skip_cleanup: true email: $NPM_EMAIL api_key: $NPM_TOKEN - diff --git a/.tx/config b/.tx/config new file mode 100644 index 0000000000000000000000000000000000000000..de8d6bb3d68424fb997ea2fb0ef3947464d94960 --- /dev/null +++ b/.tx/config @@ -0,0 +1,8 @@ +[main] +host = https://www.transifex.com + +[experimental-scratch.scratch-gui] +file_filter = translations/<lang>.json +source_file = translations/en.json +source_lang = en +type = CHROME diff --git a/package.json b/package.json index 3bd394669ea5a1f494e992c47cba1f9998e9c0b7..13a4b8c1f0dd219702d475c71207e4b18c4dfd87 100644 --- a/package.json +++ b/package.json @@ -4,16 +4,15 @@ "description": "GraphicaL User Interface for creating and running Scratch 3.0 projects", "main": "./src/index.js", "scripts": { - "build": "npm run clean && npm run i18n:msgs && webpack --progress --colors --bail", + "build": "npm run clean && webpack --progress --colors --bail", "clean": "rimraf ./build && mkdirp build", "deploy": "touch build/.nojekyll && gh-pages -t -d build -m \"Build for $(git log --pretty=format:%H -n1)\"", - "i18n:msgs": "node ./scripts/generate-locale-messages.js", "i18n:src": "babel src > tmp.js && rimraf tmp.js && ./scripts/build-i18n-source.js ./translations/messages/ ./translations/", - "lint": "eslint . --ext .js,.jsx", - "start": "npm run i18n:msgs && webpack-dev-server", - "unit-test": "jest test/unit", - "integration-test": "jest --runInBand test/integration", - "test": "npm run lint && npm run unit-test && npm run build", + "start": "webpack-dev-server", + "test": "npm run test:lint && npm run test:unit && NODE_ENV=production npm run build && npm run test:integration", + "test:integration": "jest --runInBand test[\\\\/]integration", + "test:lint": "eslint . --ext .js,.jsx", + "test:unit": "jest test[\\\\/]unit", "watch": "webpack --progress --colors --watch" }, "author": "Massachusetts Institute of Technology", @@ -24,14 +23,14 @@ "url": "git+ssh://git@github.com/LLK/scratch-gui.git" }, "peerDependencies": { - "react": "^15.6.1", - "react-dom": "^15.6.1" + "react": "^16.0.0", + "react-dom": "^16.0.0" }, "devDependencies": { "autoprefixer": "^7.1.3", "babel-cli": "^6.26.0", "babel-core": "^6.23.1", - "babel-eslint": "^7.2.3", + "babel-eslint": "^8.0.1", "babel-loader": "^7.1.0", "babel-plugin-react-intl": "^2.3.1", "babel-plugin-syntax-dynamic-import": "^6.18.0", @@ -39,15 +38,18 @@ "babel-plugin-transform-object-rest-spread": "^6.22.0", "babel-preset-es2015": "^6.22.0", "babel-preset-react": "^6.22.0", - "chromedriver": "^2.32.3", + "buffer-loader": "0.0.1", + "chromedriver": "2.33.1", "classnames": "2.2.5", "copy-webpack-plugin": "^4.0.1", "css-loader": "^0.28.7", - "enzyme": "^2.8.2", + "enzyme": "^3.1.0", + "enzyme-adapter-react-16": "1.0.3", "eslint": "^4.7.1", - "eslint-config-scratch": "^4.0.0", + "eslint-config-scratch": "^5.0.0", "eslint-plugin-import": "^2.7.0", "eslint-plugin-react": "^7.2.1", + "file-loader": "1.1.5", "get-float-time-domain-data": "0.1.0", "gh-pages": "github:rschamp/gh-pages#publish-branch-to-subfolder", "html-webpack-plugin": "^2.30.0", @@ -61,36 +63,40 @@ "lodash.pick": "4.4.0", "minilog": "3.1.0", "mkdirp": "^0.5.1", - "postcss-import": "^10.0.0", + "postcss-import": "^11.0.0", "postcss-loader": "^2.0.5", "postcss-simple-vars": "^4.0.0", "prop-types": "^15.5.10", - "react": "15.6.1", - "react-contextmenu": "2.7.0", - "react-dom": "15.6.1", + "raf": "^3.4.0", + "raw-loader": "0.5.1", + "react": "16.0.0", + "react-contextmenu": "2.8.0", + "react-dom": "16.0.0", "react-draggable": "3.0.3", "react-intl": "2.4.0", "react-intl-redux": "0.6.0", - "react-modal": "2.3.1", + "react-modal": "3.0.2", "react-redux": "5.0.6", - "react-responsive": "1.3.4", + "react-responsive": "3.0.0", "react-style-proptype": "3.0.0", - "react-tabs": "2.0.0", - "react-test-renderer": "^15.5.4", + "react-tabs": "2.1.0", + "react-test-renderer": "16.0.0", "redux": "3.7.0", "redux-mock-store": "^1.2.3", "redux-throttle": "0.1.1", "rimraf": "^2.6.1", "scratch-audio": "latest", "scratch-blocks": "latest", + "scratch-l10n": "^2.0.0", + "scratch-paint": "latest", "scratch-render": "latest", - "scratch-storage": "^0.2.0", + "scratch-storage": "^0.3.0", "scratch-vm": "latest", - "selenium-webdriver": "^3.5.0", + "selenium-webdriver": "3.5.0", "startaudiocontext": "1.2.1", - "style-loader": "^0.18.0", + "style-loader": "^0.19.0", "svg-to-image": "1.1.3", - "svg-url-loader": "^2.1.0", + "text-encoding": "0.6.4", "wav-encoder": "1.3.0", "web-audio-test-api": "^0.5.2", "webpack": "^3.6.0", @@ -98,6 +104,10 @@ "xhr": "2.4.0" }, "jest": { + "setupFiles": [ + "raf/polyfill", + "<rootDir>/test/helpers/enzyme-setup.js" + ], "testPathIgnorePatterns": [ "src/test.js" ], diff --git a/scripts/generate-locale-messages.js b/scripts/generate-locale-messages.js deleted file mode 100755 index fb6bf359a23db0c4dc583476fc18896befe8683a..0000000000000000000000000000000000000000 --- a/scripts/generate-locale-messages.js +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env node - -/* -Generates locale/messages.json from current translastion files - -Translations are expected to be in the ./translations directory. -Translation files are in Chrome i18n json format: -''' -{ - "message.id": { - "message": "The translated text", - "description": "Tips for translators" - }, - ... -} -''' -They are named by locale, for example: 'fr.json' or 'zh-cn.json' - -Current languages supported are listed in ../src/languages.json - -Converts the collection of translation files to a single set of messages. -Example output: -''' -{ - "en": { - "action.addBackdrop": "Add Backdrop", - "action.addCostume": "Add Costume", - "action.recordSound": "Record Sound", - "action.addSound": "Add Sound" - }, - "fr": { - "action.addSound": "Ajouter Son", - "action.addCostume": "Ajouter Costume", - "action.addBackdrop": "Ajouter Arrière-plan", - "action.recordSound": "Enregistrement du Son" - } -} -''' - -Missing locales are ignored, react-intl will use the default messages for them. - */ -const fs = require('fs'); -const path = require('path'); -const mkdirp = require('mkdirp'); - -const locales = ['en', 'es', 'fr']; -const LANG_DIR = './translations/'; -const MSGS_DIR = './locale/'; - -let messages = locales.reduce((collection, lang) => { - let langMessages = {}; - try { - let langData = JSON.parse( - fs.readFileSync(path.resolve(LANG_DIR, lang + '.json'), 'utf8') - ); - Object.keys(langData).forEach((id) => { - langMessages[id] = langData[id].message; - }); - collection[lang] = langMessages; - } catch (e) { - process.stdout.write(lang + ' translation file missing, will use defaults.\n'); - } - return collection; -}, {}); - -mkdirp.sync(MSGS_DIR); -fs.writeFileSync(MSGS_DIR + 'messages.json', JSON.stringify(messages, null, 2)); diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index bb336c40d7687ced45ce96dc511fab866a20812d..9f9b84743fb3a861f3b0ba2d36fb55e9d25b28be 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -150,7 +150,7 @@ } .extension-button-container { - width: 60px; + width: 3.25rem; height: 3.25rem; position: absolute; bottom: 0; diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 2257c2ceea73eeee4e4c97d661035ec50dba5d8c..354baecdb2758cd26f59c525e042a4a9caba5e85 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -26,7 +26,7 @@ const addExtensionMessage = ( <FormattedMessage defaultMessage="Extensions" description="Button to add an extension in the target pane" - id="targetPane.addExtension" + id="gui.gui.addExtension" /> ); diff --git a/src/components/language-selector/language-selector.jsx b/src/components/language-selector/language-selector.jsx index 81f2b596d1649b45791d9088414f64187008772c..3be8abaece2178eb3a88cb8e6d8c5fcace0bba98 100644 --- a/src/components/language-selector/language-selector.jsx +++ b/src/components/language-selector/language-selector.jsx @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import Box from '../box/box.jsx'; -import languages from '../../locale.js'; +import locales from 'scratch-l10n'; import languageIcon from './language-icon.svg'; import styles from './language-selector.css'; @@ -22,12 +22,12 @@ const LanguageSelector = ({ value={currentLocale} onChange={onChange} > - {Object.keys(languages).map(locale => ( + {Object.keys(locales).map(locale => ( <option key={locale} value={locale} > - {languages[locale].name} + {locales[locale].name} </option> ))} </select> diff --git a/src/components/library-item/library-item.css b/src/components/library-item/library-item.css index 067b2c1af3328a80dd5e228d810d6129f1788617..4fe6ae6a849dfedbdaa60a6c612a4060933d9390 100644 --- a/src/components/library-item/library-item.css +++ b/src/components/library-item/library-item.css @@ -58,3 +58,27 @@ white-space: nowrap; min-width: 0; } + +.featured-item { + flex-basis: 300px; + max-width: 300px; + height: 320px; + overflow: hidden; + padding: 0; +} + +.featured-image { + width: 100%; +} + +.featured-text { + font-weight: bold; + padding: 10px; + height: 140px; + width: 300px; +} + +.featured-description { + font-weight: normal; + line-height: 2; +} diff --git a/src/components/library-item/library-item.jsx b/src/components/library-item/library-item.jsx index 5e0fa5edccc905313fa9529ad083d9db9ec76683..0f1443e36363fbba8dbbc89c9474163ef64ac23c 100644 --- a/src/components/library-item/library-item.jsx +++ b/src/components/library-item/library-item.jsx @@ -4,6 +4,7 @@ import React from 'react'; import Box from '../box/box.jsx'; import styles from './library-item.css'; +import classNames from 'classnames'; class LibraryItem extends React.PureComponent { constructor (props) { @@ -25,7 +26,26 @@ class LibraryItem extends React.PureComponent { this.props.onMouseLeave(this.props.id); } render () { - return ( + return this.props.featured ? ( + <div + className={classNames(styles.libraryItem, styles.featuredItem)} + onClick={this.handleClick} + > + <div> + <img + className={styles.featuredImage} + src={this.props.iconURL} + /> + </div> + <div + className={styles.featuredText} + > + <span className={styles.libraryItemName}>{this.props.name}</span> + <br /> + <span className={styles.featuredDescription}>{this.props.description}</span> + </div> + </div> + ) : ( <Box className={styles.libraryItem} onClick={this.handleClick} @@ -48,6 +68,8 @@ class LibraryItem extends React.PureComponent { } LibraryItem.propTypes = { + description: PropTypes.string, + featured: PropTypes.bool, iconURL: PropTypes.string.isRequired, id: PropTypes.number.isRequired, name: PropTypes.string.isRequired, diff --git a/src/components/library/library.jsx b/src/components/library/library.jsx index 165716e5b59b6537ce453bd849024ed96bbd36c7..91ac1117b6606e4b900de26f433f5342f4ee634b 100644 --- a/src/components/library/library.jsx +++ b/src/components/library/library.jsx @@ -59,6 +59,8 @@ class LibraryComponent extends React.Component { dataItem.rawURL; return ( <LibraryItem + description={dataItem.description} + featured={dataItem.featured} iconURL={scratchURL} id={index} key={`item_${index}`} diff --git a/src/components/sound-editor/sound-editor.jsx b/src/components/sound-editor/sound-editor.jsx index 21c2613bfa21511fc4e0aa0f3f1e616c0efe41e5..9a0e551cbac9097c8d8cc936ce6e74c6e3638f7f 100644 --- a/src/components/sound-editor/sound-editor.jsx +++ b/src/components/sound-editor/sound-editor.jsx @@ -30,72 +30,72 @@ const BufferedInput = BufferedInputHOC(Input); const messages = defineMessages({ sound: { - id: 'soundEditor.sound', - description: 'Lable for the name of the sound', + id: 'gui.soundEditor.sound', + description: 'Label for the name of the sound', defaultMessage: 'Sound' }, play: { - id: 'soundEditor.play', + id: 'gui.soundEditor.play', description: 'Title of the button to start playing the sound', defaultMessage: 'Play' }, stop: { - id: 'soundEditor.stop', + id: 'gui.soundEditor.stop', description: 'Title of the button to stop the sound', defaultMessage: 'Stop' }, trim: { - id: 'soundEditor.trim', + id: 'gui.soundEditor.trim', description: 'Title of the button to start trimminging the sound', defaultMessage: 'Trim' }, save: { - id: 'soundEditor.save', + id: 'gui.soundEditor.save', description: 'Title of the button to save trimmed sound', defaultMessage: 'Save' }, undo: { - id: 'soundEditor.undo', + id: 'gui.soundEditor.undo', description: 'Title of the button to undo', defaultMessage: 'Undo' }, redo: { - id: 'soundEditor.redo', + id: 'gui.soundEditor.redo', description: 'Title of the button to redo', defaultMessage: 'Redo' }, faster: { - id: 'soundEditor.faster', + id: 'gui.soundEditor.faster', description: 'Title of the button to apply the faster effect', defaultMessage: 'Faster' }, slower: { - id: 'soundEditor.slower', + id: 'gui.soundEditor.slower', description: 'Title of the button to apply the slower effect', defaultMessage: 'Slower' }, echo: { - id: 'soundEditor.echo', + id: 'gui.soundEditor.echo', description: 'Title of the button to apply the echo effect', defaultMessage: 'Echo' }, robot: { - id: 'soundEditor.robot', + id: 'gui.soundEditor.robot', description: 'Title of the button to apply the robot effect', defaultMessage: 'Robot' }, louder: { - id: 'soundEditor.louder', + id: 'gui.soundEditor.louder', description: 'Title of the button to apply the louder effect', defaultMessage: 'Louder' }, softer: { - id: 'soundEditor.softer', + id: 'gui.soundEditor.softer', description: 'Title of the button to apply thr.softer effect', defaultMessage: 'Softer' }, reverse: { - id: 'soundEditor.reverse', + id: 'gui.soundEditor.reverse', description: 'Title of the button to apply the reverse effect', defaultMessage: 'Reverse' } diff --git a/src/components/sprite-selector-item/sprite-selector-item.jsx b/src/components/sprite-selector-item/sprite-selector-item.jsx index 2db6a7feacdb0b0b04babe8e07aa7bea9ecb2990..3fc6e440e74ee390e1bdaa7d9348b90e31dc0ad0 100644 --- a/src/components/sprite-selector-item/sprite-selector-item.jsx +++ b/src/components/sprite-selector-item/sprite-selector-item.jsx @@ -39,11 +39,20 @@ const SpriteSelectorItem = props => ( ) : null} <div className={styles.spriteName}>{props.name}</div> <ContextMenu id={`${props.name}-${contextMenuId++}`}> + {props.onDuplicateButtonClick ? ( + <MenuItem onClick={props.onDuplicateButtonClick}> + <FormattedMessage + defaultMessage="duplicate" + description="Menu item to duplicate in the right click menu" + id="gui.spriteSelectorItem.contextMenuDuplicate" + /> + </MenuItem> + ) : null} <MenuItem onClick={props.onDeleteButtonClick}> <FormattedMessage defaultMessage="delete" description="Menu item to delete in the right click menu" - id="contextMenu.delete" + id="gui.spriteSelectorItem.contextMenuDelete" /> </MenuItem> </ContextMenu> @@ -55,7 +64,8 @@ SpriteSelectorItem.propTypes = { costumeURL: PropTypes.string, name: PropTypes.string.isRequired, onClick: PropTypes.func, - onDeleteButtonClick: PropTypes.func, + onDeleteButtonClick: PropTypes.func.isRequired, + onDuplicateButtonClick: PropTypes.func, selected: PropTypes.bool.isRequired }; diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx index 399d8062b4253cac9f9c77bc1a31abac0e0522f1..0110bea1ff97619027f0236799bfe35fb9351325 100644 --- a/src/components/sprite-selector/sprite-selector.jsx +++ b/src/components/sprite-selector/sprite-selector.jsx @@ -14,7 +14,7 @@ const addSpriteMessage = ( <FormattedMessage defaultMessage="Add Sprite" description="Button to add a sprite in the target pane" - id="targetPane.addSprite" + id="gui.spriteSelector.addSprite" /> ); @@ -27,6 +27,7 @@ const SpriteSelectorComponent = function (props) { onChangeSpriteX, onChangeSpriteY, onDeleteSprite, + onDuplicateSprite, onNewSpriteClick, onSelectSprite, selectedId, @@ -77,6 +78,7 @@ const SpriteSelectorComponent = function (props) { selected={sprite.id === selectedId} onClick={onSelectSprite} onDeleteButtonClick={onDeleteSprite} + onDuplicateButtonClick={onDuplicateSprite} /> )) } @@ -100,6 +102,7 @@ SpriteSelectorComponent.propTypes = { onChangeSpriteX: PropTypes.func, onChangeSpriteY: PropTypes.func, onDeleteSprite: PropTypes.func, + onDuplicateSprite: PropTypes.func, onNewSpriteClick: PropTypes.func, onSelectSprite: PropTypes.func, selectedId: PropTypes.string, diff --git a/src/components/stage-selector/stage-selector.jsx b/src/components/stage-selector/stage-selector.jsx index 93002f9efb461e2b141104024711df8f5bc0b625..5ffad63e57a8701a11f2bcde648fb45fa97587a2 100644 --- a/src/components/stage-selector/stage-selector.jsx +++ b/src/components/stage-selector/stage-selector.jsx @@ -13,7 +13,7 @@ const addBackdropMessage = ( <FormattedMessage defaultMessage="Add Backdrop" description="Button to add a backdrop in the target pane" - id="targetPane.addBackdrop" + id="gui.stageSelector.targetPaneAddBackdrop" /> ); @@ -49,7 +49,7 @@ const StageSelector = props => { <FormattedMessage defaultMessage="Backdrops" description="Label for the backdrops in the stage selector" - id="stageSelector.backdrops" + id="gui.stageSelector.backdrops" /> </div> <div className={styles.count}>{backdropCount}</div> diff --git a/src/components/stage/stage.css b/src/components/stage/stage.css index f13554a027d46eabb3371396e29a6ff59b2cd478..9c784e9560d0ba06f371ea06941c29b627963d53 100644 --- a/src/components/stage/stage.css +++ b/src/components/stage/stage.css @@ -1,4 +1,5 @@ @import "../../css/units.css"; +@import "../../css/colors.css"; .stage { /* @@ -7,8 +8,6 @@ */ display: block; - border-radius: $space; - /* @todo: This is for overriding the value being set somewhere. Where is it being set? */ background-color: transparent; } @@ -31,6 +30,10 @@ .stage-wrapper { position: relative; + border-radius: $space; + border: 1px solid $ui-pane-border; + /* Keep the canvas inside the border radius */ + overflow: hidden; } .monitor-wrapper, .color-picker-wrapper { diff --git a/src/components/target-pane/target-pane.jsx b/src/components/target-pane/target-pane.jsx index 07281756df4729331ef5b7429ec9c26238404d3b..2f147447d13280cbc63dd825840bb4f33b955708 100644 --- a/src/components/target-pane/target-pane.jsx +++ b/src/components/target-pane/target-pane.jsx @@ -7,7 +7,6 @@ import BackdropLibrary from '../../containers/backdrop-library.jsx'; import CostumeLibrary from '../../containers/costume-library.jsx'; import SoundLibrary from '../../containers/sound-library.jsx'; import SpriteLibrary from '../../containers/sprite-library.jsx'; -import ExtensionLibrary from '../../containers/extension-library.jsx'; import SpriteSelectorComponent from '../sprite-selector/sprite-selector.jsx'; import StageSelector from '../../containers/stage-selector.jsx'; @@ -22,7 +21,6 @@ import styles from './target-pane.css'; */ const TargetPane = ({ editingTarget, - extensionLibraryVisible, backdropLibraryVisible, costumeLibraryVisible, soundLibraryVisible, @@ -34,12 +32,12 @@ const TargetPane = ({ onChangeSpriteX, onChangeSpriteY, onDeleteSprite, + onDuplicateSprite, onNewSpriteClick, onRequestCloseBackdropLibrary, onRequestCloseCostumeLibrary, onRequestCloseSoundLibrary, onRequestCloseSpriteLibrary, - onRequestCloseExtensionLibrary, onSelectSprite, stage, sprites, @@ -61,6 +59,7 @@ const TargetPane = ({ onChangeSpriteX={onChangeSpriteX} onChangeSpriteY={onChangeSpriteY} onDeleteSprite={onDeleteSprite} + onDuplicateSprite={onDuplicateSprite} onNewSpriteClick={onNewSpriteClick} onSelectSprite={onSelectSprite} /> @@ -76,12 +75,6 @@ const TargetPane = ({ onSelect={onSelectSprite} />} <div> - {extensionLibraryVisible ? ( - <ExtensionLibrary - vm={vm} - onRequestClose={onRequestCloseExtensionLibrary} - /> - ) : null} {spriteLibraryVisible ? ( <SpriteLibrary vm={vm} @@ -141,6 +134,7 @@ TargetPane.propTypes = { onChangeSpriteX: PropTypes.func, onChangeSpriteY: PropTypes.func, onDeleteSprite: PropTypes.func, + onDuplicateSprite: PropTypes.func, onNewSpriteClick: PropTypes.func, onRequestCloseBackdropLibrary: PropTypes.func, onRequestCloseCostumeLibrary: PropTypes.func, diff --git a/src/components/turbo-mode/turbo-mode.jsx b/src/components/turbo-mode/turbo-mode.jsx index 724af24d1e1527c2acb79de0df0083d3ef1de4e7..4475b86d6a3bca7816beaa35bdfb667a725a07ec 100644 --- a/src/components/turbo-mode/turbo-mode.jsx +++ b/src/components/turbo-mode/turbo-mode.jsx @@ -15,7 +15,7 @@ const TurboMode = () => ( <FormattedMessage defaultMessage="Turbo Mode" description="Label indicating turbo mode is active" - id="controls.turboMode" + id="gui.turboMode.active" /> </div> </div> diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 7a2a4d908f70d51656744fd9e6a218722ec6b91d..471dc6cd08643341c290f66227b723ef28dbffb1 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -8,10 +8,12 @@ import VMScratchBlocks from '../lib/blocks'; import VM from 'scratch-vm'; import Prompt from './prompt.jsx'; import BlocksComponent from '../components/blocks/blocks.jsx'; +import ExtensionLibrary from './extension-library.jsx'; import {connect} from 'react-redux'; import {updateToolbox} from '../reducers/toolbox'; import {activateColorPicker} from '../reducers/color-picker'; +import {closeExtensionLibrary} from '../reducers/modals'; const addFunctionListener = (object, property, callback) => { const oldFn = object[property]; @@ -29,6 +31,7 @@ class Blocks extends React.Component { bindAll(this, [ 'attachVM', 'detachVM', + 'handleCategorySelected', 'handlePromptStart', 'handlePromptCallback', 'handlePromptClose', @@ -53,12 +56,13 @@ class Blocks extends React.Component { componentDidMount () { this.ScratchBlocks.FieldColourSlider.activateEyedropper_ = this.props.onActivateColorPicker; - const workspaceConfig = defaultsDeep({}, Blocks.defaultOptions, this.props.options); + const workspaceConfig = defaultsDeep({}, + Blocks.defaultOptions, + this.props.options, + {toolbox: this.props.toolboxXML} + ); this.workspace = this.ScratchBlocks.inject(this.blocks, workspaceConfig); - // Load the toolbox from the GUI (otherwise we get the scratch-blocks default toolbox) - this.workspace.updateToolbox(this.props.toolboxXML); - // @todo change this when blockly supports UI events addFunctionListener(this.workspace, 'translate', this.onWorkspaceMetricsChange); addFunctionListener(this.workspace, 'zoom', this.onWorkspaceMetricsChange); @@ -69,28 +73,25 @@ class Blocks extends React.Component { return ( this.state.prompt !== nextState.prompt || this.props.isVisible !== nextProps.isVisible || - this.props.toolboxXML !== nextProps.toolboxXML + this.props.toolboxXML !== nextProps.toolboxXML || + this.props.extensionLibraryVisible !== nextProps.extensionLibraryVisible ); } componentDidUpdate (prevProps) { if (prevProps.toolboxXML !== this.props.toolboxXML) { const selectedCategoryName = this.workspace.toolbox_.getSelectedItem().name_; this.workspace.updateToolbox(this.props.toolboxXML); - // Blockly throws if we don't select a category after updating the toolbox. - /** @TODO Find a way to avoid the exception without accessing private properties. */ - this.setToolboxSelectedItemByName(selectedCategoryName); + this.workspace.toolbox_.setSelectedCategoryByName(selectedCategoryName); } if (this.props.isVisible === prevProps.isVisible) { return; } - // @todo hack to resize blockly manually in case resize happened while hidden // @todo hack to reload the workspace due to gui bug #413 if (this.props.isVisible) { // Scripts tab this.workspace.setVisible(true); this.props.vm.refreshWorkspace(); window.dispatchEvent(new Event('resize')); - this.workspace.toolbox_.refreshSelection(); } else { this.workspace.setVisible(false); } @@ -99,20 +100,6 @@ class Blocks extends React.Component { this.detachVM(); this.workspace.dispose(); } - /** - * Select a particular category in the toolbox by specifying the category name. - * This is a workaround for a bug: @see {@link componentDidUpdate} above. - * @TODO Remove this or reimplement using only public APIs. - * @param {string} name - the name of the category to select. - */ - setToolboxSelectedItemByName (name) { - const categories = this.workspace.toolbox_.categoryMenu_.categories_; - for (let i = 0; i < categories.length; i++) { - if (categories[i].name_ === name) { - this.workspace.toolbox_.setSelectedItem(categories[i]); - } - } - } attachVM () { this.workspace.addChangeListener(this.props.vm.blockListener); this.flyoutWorkspace = this.workspace @@ -189,13 +176,11 @@ class Blocks extends React.Component { this.onWorkspaceMetricsChange(); } - this.ScratchBlocks.Events.disable(); - this.workspace.clear(); - + // Remove and reattach the workspace listener (but allow flyout events) + this.workspace.removeChangeListener(this.props.vm.blockListener); const dom = this.ScratchBlocks.Xml.textToDom(data.xml); - this.ScratchBlocks.Xml.domToWorkspace(dom, this.workspace); - this.ScratchBlocks.Events.enable(); - this.workspace.toolbox_.refreshSelection(); + this.ScratchBlocks.Xml.clearWorkspaceAndLoadFromXml(dom, this.workspace); + this.workspace.addChangeListener(this.props.vm.blockListener); if (this.props.vm.editingTarget && this.state.workspaceMetrics[this.props.vm.editingTarget.id]) { const {scrollX, scrollY, scale} = this.state.workspaceMetrics[this.props.vm.editingTarget.id]; @@ -211,6 +196,9 @@ class Blocks extends React.Component { const toolboxXML = makeToolboxXML(dynamicBlocksXML); this.props.onExtensionAdded(toolboxXML); } + handleCategorySelected (categoryName) { + this.workspace.toolbox_.setSelectedCategoryByName(categoryName); + } setBlocks (blocks) { this.blocks = blocks; } @@ -227,11 +215,13 @@ class Blocks extends React.Component { render () { /* eslint-disable no-unused-vars */ const { + extensionLibraryVisible, options, vm, isVisible, onActivateColorPicker, onExtensionAdded, + onRequestCloseExtensionLibrary, toolboxXML, ...props } = this.props; @@ -251,15 +241,24 @@ class Blocks extends React.Component { onOk={this.handlePromptCallback} /> ) : null} + {extensionLibraryVisible ? ( + <ExtensionLibrary + vm={vm} + onCategorySelected={this.handleCategorySelected} + onRequestClose={onRequestCloseExtensionLibrary} + /> + ) : null} </div> ); } } Blocks.propTypes = { + extensionLibraryVisible: PropTypes.bool, isVisible: PropTypes.bool, onActivateColorPicker: PropTypes.func, onExtensionAdded: PropTypes.func, + onRequestCloseExtensionLibrary: PropTypes.func, options: PropTypes.shape({ media: PropTypes.string, zoom: PropTypes.shape({ @@ -317,6 +316,7 @@ Blocks.defaultProps = { }; const mapStateToProps = state => ({ + extensionLibraryVisible: state.modals.extensionLibrary, toolboxXML: state.toolbox.toolboxXML }); @@ -324,6 +324,9 @@ const mapDispatchToProps = dispatch => ({ onActivateColorPicker: callback => dispatch(activateColorPicker(callback)), onExtensionAdded: toolboxXML => { dispatch(updateToolbox(toolboxXML)); + }, + onRequestCloseExtensionLibrary: () => { + dispatch(closeExtensionLibrary()); } }); diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx index 594f1a7b94c9c74d46517f4ae289ba3b13962acd..d87d4964a90373f1da2789861a08fb6480ce516e 100644 --- a/src/containers/costume-tab.jsx +++ b/src/containers/costume-tab.jsx @@ -6,6 +6,7 @@ import VM from 'scratch-vm'; import AssetPanel from '../components/asset-panel/asset-panel.jsx'; import addCostumeIcon from '../components/asset-panel/icon--add-costume-lib.svg'; +import PaintEditorWrapper from './paint-editor-wrapper.jsx'; import {connect} from 'react-redux'; @@ -47,14 +48,19 @@ class CostumeTab extends React.Component { } render () { + // For paint wrapper const { - editingTarget, - sprites, - stage, + onNewBackdropClick, onNewCostumeClick, - onNewBackdropClick + ...props } = this.props; + const { + editingTarget, + sprites, + stage + } = props; + const target = editingTarget && sprites[editingTarget] ? sprites[editingTarget] : stage; if (!target) { @@ -65,14 +71,14 @@ class CostumeTab extends React.Component { <FormattedMessage defaultMessage="Add Backdrop" description="Button to add a backdrop in the editor tab" - id="action.addBackdrop" + id="gui.costumeTab.addBackdrop" /> ); const addCostumeMsg = ( <FormattedMessage defaultMessage="Add Costume" description="Button to add a costume in the editor tab" - id="action.addCostume" + id="gui.costumeTab.addCostume" /> ); @@ -90,7 +96,15 @@ class CostumeTab extends React.Component { selectedItemIndex={this.state.selectedCostumeIndex} onDeleteClick={this.handleDeleteCostume} onItemClick={this.handleSelectCostume} - /> + > + {target.costumes ? + <PaintEditorWrapper + {...props} + selectedCostumeIndex={this.state.selectedCostumeIndex} + /> : + null + } + </AssetPanel> ); } } diff --git a/src/containers/extension-library.jsx b/src/containers/extension-library.jsx index 343d0747d8a273c9eaca2d5bfbb86dfee5fa2284..09fd65ff35d9f3af78019f622fa0ed3d58b19d0b 100644 --- a/src/containers/extension-library.jsx +++ b/src/containers/extension-library.jsx @@ -3,7 +3,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import VM from 'scratch-vm'; -import extensionLibraryContent from '../lib/libraries/extensions.json'; +import extensionLibraryContent from '../lib/libraries/extensions/index'; import LibraryComponent from '../components/library/library.jsx'; import extensionIcon from '../components/sprite-selector/icon--sprite.svg'; @@ -19,7 +19,13 @@ class ExtensionLibrary extends React.PureComponent { // eslint-disable-next-line no-alert const url = item.extensionURL || prompt('Enter the URL of the extension'); if (url) { - this.props.vm.extensionManager.loadExtensionURL(url); + if (this.props.vm.extensionManager.isExtensionLoaded(url)) { + this.props.onCategorySelected(item.name); + } else { + this.props.vm.extensionManager.loadExtensionURL(url).then(() => { + this.props.onCategorySelected(item.name); + }); + } } } render () { @@ -40,6 +46,7 @@ class ExtensionLibrary extends React.PureComponent { } ExtensionLibrary.propTypes = { + onCategorySelected: PropTypes.func, onRequestClose: PropTypes.func, visible: PropTypes.bool, vm: PropTypes.instanceOf(VM).isRequired // eslint-disable-line react/no-unused-prop-types diff --git a/src/containers/paint-editor-wrapper.jsx b/src/containers/paint-editor-wrapper.jsx new file mode 100644 index 0000000000000000000000000000000000000000..6448f3e9e2b8901f392517728ccac4f8bdc74190 --- /dev/null +++ b/src/containers/paint-editor-wrapper.jsx @@ -0,0 +1,64 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import bindAll from 'lodash.bindall'; +import VM from 'scratch-vm'; + +import PaintEditor from 'scratch-paint'; + +import {connect} from 'react-redux'; + +class PaintEditorWrapper extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleUpdateName', + 'handleUpdateSvg' + ]); + } + handleUpdateName (name) { + this.props.vm.renameCostume(this.props.selectedCostumeIndex, name); + } + handleUpdateSvg (svg, rotationCenterX, rotationCenterY) { + this.props.vm.updateSvg(this.props.selectedCostumeIndex, svg, rotationCenterX, rotationCenterY); + } + render () { + if (!this.props.svgId) return null; + return ( + <PaintEditor + {...this.props} + svg={this.props.vm.getCostumeSvg(this.props.selectedCostumeIndex)} + onUpdateName={this.handleUpdateName} + onUpdateSvg={this.handleUpdateSvg} + /> + ); + } +} + +PaintEditorWrapper.propTypes = { + name: PropTypes.string, + rotationCenterX: PropTypes.number, + rotationCenterY: PropTypes.number, + selectedCostumeIndex: PropTypes.number.isRequired, + svgId: PropTypes.string, + vm: PropTypes.instanceOf(VM) +}; + +const mapStateToProps = (state, {selectedCostumeIndex}) => { + const { + editingTarget, + sprites, + stage + } = state.targets; + const target = editingTarget && sprites[editingTarget] ? sprites[editingTarget] : stage; + const costume = target && target.costumes[selectedCostumeIndex]; + return { + name: costume && costume.name, + rotationCenterX: costume && costume.rotationCenterX, + rotationCenterY: costume && costume.rotationCenterY, + svgId: editingTarget && `${editingTarget}${costume.skinId}` + }; +}; + +export default connect( + mapStateToProps +)(PaintEditorWrapper); diff --git a/src/containers/sound-tab.jsx b/src/containers/sound-tab.jsx index 3edde50a01c17d553497fa4af460427f8e5e5182..1984923d541427b16a82980d1c6bc627f4541413 100644 --- a/src/containers/sound-tab.jsx +++ b/src/containers/sound-tab.jsx @@ -76,14 +76,14 @@ class SoundTab extends React.Component { <FormattedMessage defaultMessage="Record Sound" description="Button to record a sound in the editor tab" - id="action.recordSound" + id="gui.soundTab.recordSound" /> ); const addSoundMsg = ( <FormattedMessage defaultMessage="Add Sound" description="Button to add a sound in the editor tab" - id="action.addSound" + id="gui.soundTab.addSound" /> ); diff --git a/src/containers/sprite-selector-item.jsx b/src/containers/sprite-selector-item.jsx index aa19efc1f6bb6f2180501fd378f10766de01932b..0bc01382cac731a6bcef3b52a16fe4fb4c2d757b 100644 --- a/src/containers/sprite-selector-item.jsx +++ b/src/containers/sprite-selector-item.jsx @@ -11,7 +11,8 @@ class SpriteSelectorItem extends React.Component { super(props); bindAll(this, [ 'handleClick', - 'handleDelete' + 'handleDelete', + 'handleDuplicate' ]); } handleClick (e) { @@ -24,6 +25,10 @@ class SpriteSelectorItem extends React.Component { this.props.onDeleteButtonClick(this.props.id); } } + handleDuplicate (e) { + e.stopPropagation(); // To prevent from bubbling back to handleClick + this.props.onDuplicateButtonClick(this.props.id); + } render () { const { /* eslint-disable no-unused-vars */ @@ -31,6 +36,7 @@ class SpriteSelectorItem extends React.Component { id, onClick, onDeleteButtonClick, + onDuplicateButtonClick, /* eslint-enable no-unused-vars */ ...props } = this.props; @@ -38,6 +44,7 @@ class SpriteSelectorItem extends React.Component { <SpriteSelectorItemComponent onClick={this.handleClick} onDeleteButtonClick={this.handleDelete} + onDuplicateButtonClick={onDuplicateButtonClick ? this.handleDuplicate : null} {...props} /> ); @@ -50,7 +57,8 @@ SpriteSelectorItem.propTypes = { id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), name: PropTypes.string, onClick: PropTypes.func, - onDeleteButtonClick: PropTypes.func, + onDeleteButtonClick: PropTypes.func.isRequired, + onDuplicateButtonClick: PropTypes.func, selected: PropTypes.bool }; diff --git a/src/containers/stage.jsx b/src/containers/stage.jsx index 6d3d58211c4bda939c12a687d42ab3ef8c77f3a0..1de93d23d36bbc49ad6a3930c3520a6041af96d3 100644 --- a/src/containers/stage.jsx +++ b/src/containers/stage.jsx @@ -179,14 +179,16 @@ class Stage extends React.Component { this.updateRect(); const {x, y} = getEventXY(e); const mousePosition = [x - this.rect.left, y - this.rect.top]; - this.setState({ - mouseDown: true, - mouseDownPosition: mousePosition, - mouseDownTimeoutId: setTimeout( - this.onStartDrag.bind(this, mousePosition[0], mousePosition[1]), - 500 - ) - }); + if (e.button === 0) { + this.setState({ + mouseDown: true, + mouseDownPosition: mousePosition, + mouseDownTimeoutId: setTimeout( + this.onStartDrag.bind(this, mousePosition[0], mousePosition[1]), + 500 + ) + }); + } const data = { isDown: true, x: mousePosition[0], diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx index 0ec1c43ff16e053aa19509f34bc2aa1e1469aa0e..ba0438fae049144130cbcae35e4e8abf7fbdee3b 100644 --- a/src/containers/target-pane.jsx +++ b/src/containers/target-pane.jsx @@ -7,7 +7,6 @@ import { openSpriteLibrary, closeBackdropLibrary, closeCostumeLibrary, - closeExtensionLibrary, closeSoundLibrary, closeSpriteLibrary } from '../reducers/modals'; @@ -25,6 +24,7 @@ class TargetPane extends React.Component { 'handleChangeSpriteX', 'handleChangeSpriteY', 'handleDeleteSprite', + 'handleDuplicateSprite', 'handleSelectSprite' ]); } @@ -49,6 +49,9 @@ class TargetPane extends React.Component { handleDeleteSprite (id) { this.props.vm.deleteSprite(id); } + handleDuplicateSprite (id) { + this.props.vm.duplicateSprite(id); + } handleSelectSprite (id) { this.props.vm.setEditingTarget(id); } @@ -63,6 +66,7 @@ class TargetPane extends React.Component { onChangeSpriteX={this.handleChangeSpriteX} onChangeSpriteY={this.handleChangeSpriteY} onDeleteSprite={this.handleDeleteSprite} + onDuplicateSprite={this.handleDuplicateSprite} onSelectSprite={this.handleSelectSprite} /> ); @@ -92,8 +96,7 @@ const mapStateToProps = state => ({ soundLibraryVisible: state.modals.soundLibrary, spriteLibraryVisible: state.modals.spriteLibrary, costumeLibraryVisible: state.modals.costumeLibrary, - backdropLibraryVisible: state.modals.backdropLibrary, - extensionLibraryVisible: state.modals.extensionLibrary + backdropLibraryVisible: state.modals.backdropLibrary }); const mapDispatchToProps = dispatch => ({ onNewSpriteClick: e => { @@ -106,9 +109,6 @@ const mapDispatchToProps = dispatch => ({ onRequestCloseCostumeLibrary: () => { dispatch(closeCostumeLibrary()); }, - onRequestCloseExtensionLibrary: () => { - dispatch(closeExtensionLibrary()); - }, onRequestCloseSoundLibrary: () => { dispatch(closeSoundLibrary()); }, diff --git a/src/examples/compatibility-testing.jsx b/src/examples/compatibility-testing.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3fa8e71d94e34e4722ec1a1f329c9df46de2688c --- /dev/null +++ b/src/examples/compatibility-testing.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import {connect} from 'react-redux'; + +import AppStateHOC from '../lib/app-state-hoc.jsx'; +import Controls from '../containers/controls.jsx'; +import Stage from '../containers/stage.jsx'; +import Box from '../components/box/box.jsx'; +import GUI from '../containers/gui.jsx'; +import ProjectLoaderHOC from '../lib/project-loader-hoc.jsx'; + +const mapStateToProps = state => ({vm: state.vm}); + +const VMStage = connect(mapStateToProps)(Stage); +const VMControls = connect(mapStateToProps)(Controls); + +const DEFAULT_PROJECT_ID = '10015059'; + +class Player extends React.Component { + constructor (props) { + super(props); + this.updateProject = this.updateProject.bind(this); + + this.state = { + projectId: window.location.hash.substring(1) || DEFAULT_PROJECT_ID + }; + } + componentDidMount () { + window.addEventListener('hashchange', this.updateProject); + if (!window.location.hash.substring(1)) { + window.location.hash = DEFAULT_PROJECT_ID; + } + } + componentWillUnmount () { + window.addEventListener('hashchange', this.updateProject); + } + updateProject () { + this.setState({projectId: window.location.hash.substring(1)}); + } + render () { + const width = 480; + const height = 360; + return ( + <div style={{display: 'flex'}}> + <GUI + {...this.props} + width={width} + > + <Box height={40}> + <VMControls + style={{ + marginRight: 10, + height: 40 + }} + /> + </Box> + <VMStage + height={height} + width={width} + /> + </GUI> + <iframe + allowFullScreen + allowTransparency + frameBorder="0" + height="402" + src={`https://scratch.mit.edu/projects/embed/${this.state.projectId}/?autostart=true`} + width="485" + /> + </div> + ); + } +} + +const App = AppStateHOC(ProjectLoaderHOC(Player)); + +const appTarget = document.createElement('div'); +document.body.appendChild(appTarget); + +ReactDOM.render(<App />, appTarget); diff --git a/src/lib/default-project/09dc888b0b7df19f70d81588ae73420e.svg b/src/lib/default-project/09dc888b0b7df19f70d81588ae73420e.svg new file mode 100755 index 0000000000000000000000000000000000000000..d449b3d15b955647f2198116743d0f4df619b24b Binary files /dev/null and b/src/lib/default-project/09dc888b0b7df19f70d81588ae73420e.svg differ diff --git a/src/lib/default-project/3696356a03a8d938318876a593572843.svg b/src/lib/default-project/3696356a03a8d938318876a593572843.svg new file mode 100755 index 0000000000000000000000000000000000000000..0ecb2de81d9e76af3920bab9afd30cec1fa9f8d6 Binary files /dev/null and b/src/lib/default-project/3696356a03a8d938318876a593572843.svg differ diff --git a/src/lib/default-project/5c81a336fab8be57adc039a8a2b33ca9.png b/src/lib/default-project/5c81a336fab8be57adc039a8a2b33ca9.png new file mode 100755 index 0000000000000000000000000000000000000000..da373d2cf3ab7c4c617d307eec5a044ae43917ee Binary files /dev/null and b/src/lib/default-project/5c81a336fab8be57adc039a8a2b33ca9.png differ diff --git a/src/lib/default-project/739b5e2a2435f6e1ec2993791b423146.png b/src/lib/default-project/739b5e2a2435f6e1ec2993791b423146.png new file mode 100755 index 0000000000000000000000000000000000000000..b395ac94c246fd4de8368a156f92010baa2abe21 Binary files /dev/null and b/src/lib/default-project/739b5e2a2435f6e1ec2993791b423146.png differ diff --git a/src/lib/default-project/83a9787d4cb6f3b7632b4ddfebf74367.wav b/src/lib/default-project/83a9787d4cb6f3b7632b4ddfebf74367.wav new file mode 100755 index 0000000000000000000000000000000000000000..fc3b2724a9c7cfef378eeb65499d44236ad2add8 Binary files /dev/null and b/src/lib/default-project/83a9787d4cb6f3b7632b4ddfebf74367.wav differ diff --git a/src/lib/default-project/83c36d806dc92327b9e7049a565c6bff.wav b/src/lib/default-project/83c36d806dc92327b9e7049a565c6bff.wav new file mode 100755 index 0000000000000000000000000000000000000000..45742d5ef6f09d05b0f0788cb055ffe54abfd9ad Binary files /dev/null and b/src/lib/default-project/83c36d806dc92327b9e7049a565c6bff.wav differ diff --git a/src/lib/default-project/index.js b/src/lib/default-project/index.js new file mode 100644 index 0000000000000000000000000000000000000000..bc1ace99984c8fc8982f59dcba5fdcb3f3bb7386 --- /dev/null +++ b/src/lib/default-project/index.js @@ -0,0 +1,49 @@ +import {TextEncoder} from 'text-encoding'; +import projectJson from './project.json'; + +/* eslint-disable import/no-unresolved */ +import popWav from '!buffer-loader!./83a9787d4cb6f3b7632b4ddfebf74367.wav'; +import meowWav from '!buffer-loader!./83c36d806dc92327b9e7049a565c6bff.wav'; +import backdrop from '!buffer-loader!./739b5e2a2435f6e1ec2993791b423146.png'; +import penLayer from '!buffer-loader!./5c81a336fab8be57adc039a8a2b33ca9.png'; +import costume1 from '!raw-loader!./09dc888b0b7df19f70d81588ae73420e.svg'; +import costume2 from '!raw-loader!./3696356a03a8d938318876a593572843.svg'; +/* eslint-enable import/no-unresolved */ + +const encoder = new TextEncoder(); +export default [{ + id: 0, + assetType: 'Project', + dataFormat: 'JSON', + data: JSON.stringify(projectJson) +}, { + id: '83a9787d4cb6f3b7632b4ddfebf74367', + assetType: 'Sound', + dataFormat: 'WAV', + data: popWav +}, { + id: '83c36d806dc92327b9e7049a565c6bff', + assetType: 'Sound', + dataFormat: 'WAV', + data: meowWav +}, { + id: '739b5e2a2435f6e1ec2993791b423146', + assetType: 'ImageBitmap', + dataFormat: 'PNG', + data: backdrop +}, { + id: '5c81a336fab8be57adc039a8a2b33ca9', + assetType: 'ImageBitmap', + dataFormat: 'PNG', + data: penLayer +}, { + id: '09dc888b0b7df19f70d81588ae73420e', + assetType: 'ImageVector', + dataFormat: 'SVG', + data: encoder.encode(costume1) +}, { + id: '3696356a03a8d938318876a593572843', + assetType: 'ImageVector', + dataFormat: 'SVG', + data: encoder.encode(costume2) +}]; diff --git a/src/lib/empty-project.json b/src/lib/default-project/project.json similarity index 100% rename from src/lib/empty-project.json rename to src/lib/default-project/project.json diff --git a/src/lib/libraries/extensions.json b/src/lib/libraries/extensions.json deleted file mode 100644 index d23bb965a1876e8029af5f9a6593f09ab565b109..0000000000000000000000000000000000000000 --- a/src/lib/libraries/extensions.json +++ /dev/null @@ -1,7 +0,0 @@ -[ - { - "name": "Some Blocks", - "extensionURL": "static/extensions/example-extension.js", - "md5": "76f99773dd4eb59f86cd11bba6ad3cb9.svg" - } -] diff --git a/src/lib/libraries/extensions/index.js b/src/lib/libraries/extensions/index.js new file mode 100644 index 0000000000000000000000000000000000000000..4a03a896bba145c04175765e3b2b7ad9fa41557f --- /dev/null +++ b/src/lib/libraries/extensions/index.js @@ -0,0 +1,19 @@ +import penImage from './pen.png'; +import wedoImage from './wedo.png'; + +export default [ + { + name: 'Pen', + extensionURL: 'pen', + iconURL: penImage, + description: 'Draw with your sprites.', + featured: true + }, + { + name: 'Lego WeDo 2.0', + extensionURL: 'wedo2', + iconURL: wedoImage, + description: 'Build with motors and sensors.', + featured: true + } +]; diff --git a/src/lib/libraries/extensions/pen.png b/src/lib/libraries/extensions/pen.png new file mode 100644 index 0000000000000000000000000000000000000000..f02064c425e89cbac6962b36ee0b90e50d145e03 Binary files /dev/null and b/src/lib/libraries/extensions/pen.png differ diff --git a/src/lib/libraries/extensions/wedo.png b/src/lib/libraries/extensions/wedo.png new file mode 100644 index 0000000000000000000000000000000000000000..e8519256752af7d5cbbbe86b74df1cce7fe5614d Binary files /dev/null and b/src/lib/libraries/extensions/wedo.png differ diff --git a/src/lib/make-toolbox-xml.js b/src/lib/make-toolbox-xml.js index 7561bb2b04bdbfcbc3f709d7fb564b84ad9e8543..b186ba8e45041d41bec540b603124b6b5977cd7e 100644 --- a/src/lib/make-toolbox-xml.js +++ b/src/lib/make-toolbox-xml.js @@ -1,66 +1,6 @@ -const separator = '<sep gap="45"/>'; +const categorySeparator = '<sep gap="36"/>'; -const top = ` - <category name="Top" colour="#FFFFFF" secondaryColour="#CCCCCC"> - <block type="event_whenflagclicked"/> - <block type="event_whenkeypressed"> - </block> - <block type="event_whenthisspriteclicked"/> - <block type="motion_movesteps"> - <value name="STEPS"> - <shadow type="math_number"> - <field name="NUM">10</field> - </shadow> - </value> - </block> - <block type="motion_turnright"> - <value name="DEGREES"> - <shadow type="math_number"> - <field name="NUM">15</field> - </shadow> - </value> - </block> - <block type="motion_ifonedgebounce"/> - <block type="sound_playuntildone"> - <value name="SOUND_MENU"> - <shadow type="sound_sounds_menu"/> - </value> - </block> - <block type="looks_changeeffectby"> - <value name="CHANGE"> - <shadow type="math_number"> - <field name="NUM">10</field> - </shadow> - </value> - </block> - <block type="control_repeat"> - <value name="TIMES"> - <shadow type="math_whole_number"> - <field name="NUM">10</field> - </shadow> - </value> - </block> - <block type="control_wait"> - <value name="DURATION"> - <shadow type="math_positive_number"> - <field name="NUM">1</field> - </shadow> - </value> - </block> - <block type="operator_random"> - <value name="FROM"> - <shadow type="math_number"> - <field name="NUM">1</field> - </shadow> - </value> - <value name="TO"> - <shadow type="math_number"> - <field name="NUM">10</field> - </shadow> - </value> - </block> - </category> -`; +const blockSeparator = '<sep gap="36"/>'; // At default scale, about 28px const motion = ` <category name="Motion" colour="#4C97FF" secondaryColour="#3373CC"> @@ -85,6 +25,7 @@ const motion = ` </shadow> </value> </block> + ${blockSeparator} <block type="motion_pointindirection"> <value name="DIRECTION"> <shadow type="math_angle"> @@ -98,14 +39,15 @@ const motion = ` </shadow> </value> </block> + ${blockSeparator} <block type="motion_gotoxy"> <value name="X"> - <shadow type="math_number"> + <shadow id="movex" type="math_number"> <field name="NUM">0</field> </shadow> </value> <value name="Y"> - <shadow type="math_number"> + <shadow id="movey" type="math_number"> <field name="NUM">0</field> </shadow> </value> @@ -123,12 +65,12 @@ const motion = ` </shadow> </value> <value name="X"> - <shadow type="math_number"> + <shadow id="glidex" type="math_number"> <field name="NUM">0</field> </shadow> </value> <value name="Y"> - <shadow type="math_number"> + <shadow id="glidey" type="math_number"> <field name="NUM">0</field> </shadow> </value> @@ -144,6 +86,7 @@ const motion = ` </shadow> </value> </block> + ${blockSeparator} <block type="motion_changexby"> <value name="DX"> <shadow type="math_number"> @@ -153,7 +96,7 @@ const motion = ` </block> <block type="motion_setx"> <value name="X"> - <shadow type="math_number"> + <shadow id="setx" type="math_number"> <field name="NUM">0</field> </shadow> </value> @@ -167,23 +110,67 @@ const motion = ` </block> <block type="motion_sety"> <value name="Y"> - <shadow type="math_number"> + <shadow id="sety" type="math_number"> <field name="NUM">0</field> </shadow> </value> </block> + ${blockSeparator} <block type="motion_ifonedgebounce"/> + ${blockSeparator} <block type="motion_setrotationstyle"/> - <block type="motion_xposition"/> - <block type="motion_yposition"/> - <block type="motion_direction"/> + ${blockSeparator} + <block id="xposition" type="motion_xposition"/> + <block id="yposition" type="motion_yposition"/> + <block id="direction" type="motion_direction"/> + ${categorySeparator} </category> `; const looks = ` <category name="Looks" colour="#9966FF" secondaryColour="#774DCB"> + <block type="looks_sayforsecs"> + <value name="MESSAGE"> + <shadow type="text"> + <field name="TEXT">Hello!</field> + </shadow> + </value> + <value name="SECS"> + <shadow type="math_number"> + <field name="NUM">2</field> + </shadow> + </value> + </block> + <block type="looks_say"> + <value name="MESSAGE"> + <shadow type="text"> + <field name="TEXT">Hello!</field> + </shadow> + </value> + </block> + <block type="looks_thinkforsecs"> + <value name="MESSAGE"> + <shadow type="text"> + <field name="TEXT">Hmm...</field> + </shadow> + </value> + <value name="SECS"> + <shadow type="math_number"> + <field name="NUM">2</field> + </shadow> + </value> + </block> + <block type="looks_think"> + <value name="MESSAGE"> + <shadow type="text"> + <field name="TEXT">Hmm...</field> + </shadow> + </value> + </block> + ${blockSeparator} <block type="looks_show"/> <block type="looks_hide"/> + ${blockSeparator} <block type="looks_switchcostumeto"> <value name="COSTUME"> <shadow type="looks_costume"/> @@ -201,6 +188,7 @@ const looks = ` <shadow type="looks_backdrops"/> </value> </block> + ${blockSeparator} <block type="looks_changeeffectby"> <value name="CHANGE"> <shadow type="math_number"> @@ -216,6 +204,7 @@ const looks = ` </value> </block> <block type="looks_cleargraphiceffects"/> + ${blockSeparator} <block type="looks_changesizeby"> <value name="CHANGE"> <shadow type="math_number"> @@ -230,6 +219,7 @@ const looks = ` </shadow> </value> </block> + ${blockSeparator} <block type="looks_gotofront"/> <block type="looks_gobacklayers"> <value name="NUM"> @@ -238,10 +228,12 @@ const looks = ` </shadow> </value> </block> - <block type="looks_costumeorder"/> - <block type="looks_backdroporder"/> - <block type="looks_backdropname"/> - <block type="looks_size"/> + ${blockSeparator} + <block id="costumeorder" type="looks_costumeorder"/> + <block id="backdroporder" type="looks_backdroporder"/> + <block id="backdropname" type="looks_backdropname"/> + <block id="size" type="looks_size"/> + ${categorySeparator} </category> `; @@ -258,6 +250,7 @@ const sound = ` </value> </block> <block type="sound_stopallsounds"/> + ${blockSeparator} <block type="sound_playdrumforbeats"> <value name="DRUM"> <shadow type="sound_drums_menu"/> @@ -275,6 +268,7 @@ const sound = ` </shadow> </value> </block> + ${blockSeparator} <block type="sound_playnoteforbeats"> <value name="NOTE"> <shadow type="math_number"> @@ -292,6 +286,7 @@ const sound = ` <shadow type="sound_instruments_menu"/> </value> </block> + ${blockSeparator} <block type="sound_changeeffectby"> <value name="VALUE"> <shadow type="math_number"> @@ -307,6 +302,7 @@ const sound = ` </value> </block> <block type="sound_cleareffects"/> + ${blockSeparator} <block type="sound_changevolumeby"> <value name="VOLUME"> <shadow type="math_number"> @@ -321,7 +317,8 @@ const sound = ` </shadow> </value> </block> - <block type="sound_volume"/> + <block id="volume" type="sound_volume"/> + ${blockSeparator} <block type="sound_changetempoby"> <value name="TEMPO"> <shadow type="math_number"> @@ -336,78 +333,8 @@ const sound = ` </shadow> </value> </block> - <block type="sound_tempo"/> - </category> -`; - -const pen = ` - <category name="Pen" colour="#00B295" secondaryColour="#0B8E69"> - <block type="pen_clear"/> - <block type="pen_stamp"/> - <block type="pen_pendown"/> - <block type="pen_penup"/> - <block type="pen_setpencolortocolor"> - <value name="COLOR"> - <shadow type="colour_picker"> - </shadow> - </value> - </block> - <block type="pen_changepencolorby"> - <value name="COLOR"> - <shadow type="math_number"> - <field name="NUM">10</field> - </shadow> - </value> - </block> - <block type="pen_setpencolortonum"> - <value name="COLOR"> - <shadow type="math_number"> - <field name="NUM">0</field> - </shadow> - </value> - </block> - <block type="pen_changepenshadeby"> - <value name="SHADE"> - <shadow type="math_number"> - <field name="NUM">10</field> - </shadow> - </value> - </block> - <block type="pen_setpenshadeto"> - <value name="SHADE"> - <shadow type="math_number"> - <field name="NUM">50</field> - </shadow> - </value> - </block> - <block type="pen_changepensizeby"> - <value name="SIZE"> - <shadow type="math_number"> - <field name="NUM">1</field> - </shadow> - </value> - </block> - <block type="pen_setpensizeto"> - <value name="SIZE"> - <shadow type="math_number"> - <field name="NUM">1</field> - </shadow> - </value> - </block> - <block type="pen_changepentransparencyby" id="pen_changepentransparencyby"> - <value name="TRANSPARENCY"> - <shadow type="math_number"> - <field name="NUM">10</field> - </shadow> - </value> - </block> - <block type="pen_setpentransparencyto" id="pen_setpentransparencyto"> - <value name="TRANSPARENCY"> - <shadow type="math_number"> - <field name="NUM">50</field> - </shadow> - </value> - </block> + <block id="tempo" type="sound_tempo"/> + ${categorySeparator} </category> `; @@ -419,6 +346,7 @@ const events = ` <block type="event_whenthisspriteclicked"/> <block type="event_whenbackdropswitchesto"> </block> + ${blockSeparator} <block type="event_whengreaterthan"> <value name="VALUE"> <shadow type="math_number"> @@ -426,6 +354,7 @@ const events = ` </shadow> </value> </block> + ${blockSeparator} <block type="event_whenbroadcastreceived"> </block> <block type="event_broadcast"> @@ -438,6 +367,7 @@ const events = ` <shadow type="event_broadcast_menu"/> </value> </block> + ${categorySeparator} </category> `; @@ -450,6 +380,7 @@ const control = ` </shadow> </value> </block> + ${blockSeparator} <block type="control_repeat"> <value name="TIMES"> <shadow type="math_whole_number"> @@ -458,11 +389,14 @@ const control = ` </value> </block> <block type="control_forever"/> + ${blockSeparator} <block type="control_if"/> <block type="control_if_else"/> <block type="control_wait_until"/> <block type="control_repeat_until"/> + ${blockSeparator} <block type="control_stop"/> + ${blockSeparator} <block type="control_start_as_clone"/> <block type="control_create_clone_of"> <value name="CLONE_OPTION"> @@ -470,6 +404,7 @@ const control = ` </value> </block> <block type="control_delete_this_clone"/> + ${categorySeparator} </category> `; @@ -498,6 +433,7 @@ const sensing = ` <shadow type="sensing_distancetomenu"/> </value> </block> + ${blockSeparator} <block type="sensing_keypressed"> <value name="KEY_OPTION"> <shadow type="sensing_keyoptions"/> @@ -506,23 +442,28 @@ const sensing = ` <block type="sensing_mousedown"/> <block type="sensing_mousex"/> <block type="sensing_mousey"/> - <block type="sensing_loudness"/> - <block type="sensing_timer"/> + ${blockSeparator} + <block id="loudness" type="sensing_loudness"/> + ${blockSeparator} + <block id="timer" type="sensing_timer"/> <block type="sensing_resettimer"/> - <block type="sensing_of"> + ${blockSeparator} + <block id="of" type="sensing_of"> <value name="PROPERTY"> - <shadow type="sensing_of_property_menu"/> + <shadow id="sensing_of_property_menu" type="sensing_of_property_menu"/> </value> <value name="OBJECT"> - <shadow type="sensing_of_object_menu"/> + <shadow id="sensing_of_object_menu" type="sensing_of_object_menu"/> </value> </block> - <block type="sensing_current"> + ${blockSeparator} + <block id="current" type="sensing_current"> <value name="CURRENTMENU"> - <shadow type="sensing_currentmenu"/> + <shadow id="sensing_currentmenu" type="sensing_currentmenu"/> </value> </block> <block type="sensing_dayssince2000"/> + ${categorySeparator} </category> `; @@ -576,6 +517,7 @@ const operators = ` </shadow> </value> </block> + ${blockSeparator} <block type="operator_random"> <value name="FROM"> <shadow type="math_number"> @@ -588,6 +530,7 @@ const operators = ` </shadow> </value> </block> + ${blockSeparator} <block type="operator_lt"> <value name="OPERAND1"> <shadow type="text"> @@ -624,9 +567,11 @@ const operators = ` </shadow> </value> </block> + ${blockSeparator} <block type="operator_and"/> <block type="operator_or"/> <block type="operator_not"/> + ${blockSeparator} <block type="operator_join"> <value name="STRING1"> <shadow type="text"> @@ -658,6 +603,19 @@ const operators = ` </shadow> </value> </block> + <block type="operator_contains" id="operator_contains"> + <value name="STRING1"> + <shadow type="text"> + <field name="TEXT">hello</field> + </shadow> + </value> + <value name="STRING2"> + <shadow type="text"> + <field name="TEXT">world</field> + </shadow> + </value> + </block> + ${blockSeparator} <block type="operator_mod"> <value name="NUM1"> <shadow type="math_number"> @@ -677,6 +635,7 @@ const operators = ` </shadow> </value> </block> + ${blockSeparator} <block type="operator_mathop"> <value name="NUM"> <shadow type="math_number"> @@ -684,6 +643,7 @@ const operators = ` </shadow> </value> </block> + ${categorySeparator} </category> `; @@ -700,18 +660,16 @@ const xmlClose = '</xml>'; * @returns {string} - a ScratchBlocks-style XML document for the contents of the toolbox. */ const makeToolboxXML = function (categoriesXML) { - const gap = [separator]; + const gap = [categorySeparator]; const everything = [ xmlOpen, - top, gap, motion, gap, looks, gap, sound, gap, events, gap, control, gap, sensing, gap, - pen, gap, operators, gap, data ]; @@ -721,7 +679,6 @@ const makeToolboxXML = function (categoriesXML) { } everything.push(xmlClose); - return everything.join('\n'); }; diff --git a/src/lib/project-loader-hoc.jsx b/src/lib/project-loader-hoc.jsx index 5468e7a09b54c292c9e107183fff80550fb0c29d..23ad4694ad0fbbe34340f7eda68698ae3405d07d 100644 --- a/src/lib/project-loader-hoc.jsx +++ b/src/lib/project-loader-hoc.jsx @@ -1,26 +1,7 @@ import React from 'react'; -import xhr from 'xhr'; import log from './log'; -import emptyProject from './empty-project.json'; - -class ProjectLoaderConstructor { - get DEFAULT_PROJECT_DATA () { - return emptyProject; - } - - load (id, callback) { - callback = callback || (err => log.error(err)); - xhr({ - uri: `https://projects.scratch.mit.edu/internalapi/project/${id}/get/` - }, (err, res, body) => { - if (err) return callback(err); - callback(null, body); - }); - } -} - -const ProjectLoader = new ProjectLoaderConstructor(); +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) @@ -35,13 +16,23 @@ const ProjectLoaderHOC = function (WrappedComponent) { this.updateProject = this.updateProject.bind(this); this.state = { projectId: null, - projectData: this.fetchProjectId().length ? null : JSON.stringify(ProjectLoader.DEFAULT_PROJECT_DATA) + projectData: null }; } componentDidMount () { window.addEventListener('hashchange', this.updateProject); this.updateProject(); } + componentDidUpdate (prevProps, prevState) { + if (this.state.projectId !== prevState.projectId) { + storage + .load(storage.AssetType.Project, this.state.projectId, storage.DataFormat.JSON) + .then(projectAsset => projectAsset && this.setState({ + projectData: projectAsset.data.toString() + })) + .catch(err => log.error(err)); + } + } componentWillUnmount () { window.removeEventListener('hashchange', this.updateProject); } @@ -49,18 +40,9 @@ const ProjectLoaderHOC = function (WrappedComponent) { return window.location.hash.substring(1); } updateProject () { - const projectId = this.fetchProjectId(); + let projectId = this.fetchProjectId(); if (projectId !== this.state.projectId) { - if (projectId.length < 1) { - return this.setState({ - projectId: projectId, - projectData: JSON.stringify(ProjectLoader.DEFAULT_PROJECT_DATA) - }); - } - ProjectLoader.load(projectId, (err, body) => { - if (err) return log.error(err); - this.setState({projectData: body}); - }); + if (projectId.length < 1) projectId = 0; this.setState({projectId: projectId}); } } @@ -80,6 +62,5 @@ const ProjectLoaderHOC = function (WrappedComponent) { export { - ProjectLoaderHOC as default, - ProjectLoader + ProjectLoaderHOC as default }; diff --git a/src/lib/storage.js b/src/lib/storage.js index e1dc91bd6277148f3102a47aca8b7afd2a5401c3..6d858e6c20e57f919cbaa6dfc3eb2080b9f915ce 100644 --- a/src/lib/storage.js +++ b/src/lib/storage.js @@ -1,5 +1,7 @@ import ScratchStorage from 'scratch-storage'; +import defaultProjectAssets from './default-project'; + const PROJECT_SERVER = 'https://cdn.projects.scratch.mit.edu'; const ASSET_SERVER = 'https://cdn.assets.scratch.mit.edu'; @@ -23,7 +25,15 @@ class Storage extends ScratchStorage { [this.AssetType.ImageVector, this.AssetType.ImageBitmap, this.AssetType.Sound], asset => `${ASSET_SERVER}/internalapi/asset/${asset.assetId}.${asset.dataFormat}/get/` ); + defaultProjectAssets.forEach(asset => this.cache( + this.AssetType[asset.assetType], + this.DataFormat[asset.dataFormat], + asset.data, + asset.id + )); } } -export default Storage; +const storage = new Storage(); + +export default storage; diff --git a/src/locale.js b/src/locale.js deleted file mode 100644 index ad3fcff79710fa84116934935a5953d9f83119f3..0000000000000000000000000000000000000000 --- a/src/locale.js +++ /dev/null @@ -1,23 +0,0 @@ -import localeDataEn from 'react-intl/locale-data/en'; -import localeDataEs from 'react-intl/locale-data/es'; -import localeDataFr from 'react-intl/locale-data/fr'; - -import messages from '../locale/messages.json'; // eslint-disable-line import/no-unresolved - -export default { - en: { - name: 'English', - localeData: localeDataEn, - messages: messages.en - }, - es: { - name: 'Español', - localeData: localeDataEs, - messages: messages.es - }, - fr: { - name: 'Français', - localeData: localeDataFr, - messages: messages.fr - } -}; diff --git a/src/reducers/gui.js b/src/reducers/gui.js index fef037d9231a94a541eeaf9b8e490898c4f14d0e..c9e5e41928931dca615abf8de677f693adc36dfa 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -6,6 +6,7 @@ import monitorReducer from './monitors'; import targetReducer from './targets'; import toolboxReducer from './toolbox'; import vmReducer from './vm'; +import {ScratchPaintReducer} from 'scratch-paint'; export default combineReducers({ colorPicker: colorPickerReducer, @@ -14,5 +15,6 @@ export default combineReducers({ monitors: monitorReducer, targets: targetReducer, toolbox: toolboxReducer, - vm: vmReducer + vm: vmReducer, + scratchPaint: ScratchPaintReducer }); diff --git a/src/reducers/intl.js b/src/reducers/intl.js index 51bce5e56350756287d12bf9273f2b2e1edf5ba8..12841c407e2bcea48ebe4f870cebfcf0b2688734 100644 --- a/src/reducers/intl.js +++ b/src/reducers/intl.js @@ -1,25 +1,30 @@ 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 locales from '../locale.js'; +import localeData from 'scratch-l10n'; +import guiMessages from 'scratch-l10n/locales/gui-msgs'; +import paintMessages from 'scratch-l10n/locales/paint-msgs'; -Object.keys(locales).forEach(locale => { +const combinedMessages = defaultsDeep({}, guiMessages.messages, paintMessages.messages); + +Object.keys(localeData).forEach(locale => { // TODO: will need to handle locales not in the default intl - see www/custom-locales - addLocaleData(locales[locale].localeData); + addLocaleData(localeData[locale].localeData); }); const intlInitialState = { intl: { defaultLocale: 'en', locale: 'en', - messages: locales.en.messages + messages: combinedMessages.en.messages } }; const updateIntl = locale => superUpdateIntl({ locale: locale, - messages: locales[locale].messages || locales.en.messages + messages: combinedMessages[locale].messages || combinedMessages.en.messages }); export { diff --git a/src/reducers/vm.js b/src/reducers/vm.js index 5d0e6f91f0cf707557316d63778e406c58f606c8..dd70f3f4fd756e17e76655ff149559cefd7ffbee 100644 --- a/src/reducers/vm.js +++ b/src/reducers/vm.js @@ -1,9 +1,9 @@ import VM from 'scratch-vm'; -import Storage from '../lib/storage'; +import storage from '../lib/storage'; const SET_VM = 'scratch-gui/vm/SET_VM'; const defaultVM = new VM(); -defaultVM.attachStorage(new Storage()); +defaultVM.attachStorage(storage); const initialState = defaultVM; const reducer = function (state, action) { diff --git a/test/helpers/enzyme-setup.js b/test/helpers/enzyme-setup.js new file mode 100644 index 0000000000000000000000000000000000000000..5374f679d38ef65d773fdc12133032aae63c37b5 --- /dev/null +++ b/test/helpers/enzyme-setup.js @@ -0,0 +1,4 @@ +import Enzyme from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +Enzyme.configure({adapter: new Adapter()}); diff --git a/test/helpers/selenium-helper.js b/test/helpers/selenium-helper.js index b250bcbe5e82798a4d05c426de198a967cc7a180..c4f36ca05b712e36c30246c269d47c6c1afa78ce 100644 --- a/test/helpers/selenium-helper.js +++ b/test/helpers/selenium-helper.js @@ -1,6 +1,7 @@ jasmine.DEFAULT_TIMEOUT_INTERVAL = 30000; // eslint-disable-line no-undef import bindAll from 'lodash.bindall'; +import 'chromedriver'; // register path import webdriver from 'selenium-webdriver'; const {By, until} = webdriver; @@ -11,6 +12,7 @@ class SeleniumHelper { 'clickText', 'clickButton', 'clickXpath', + 'findByText', 'findByXpath', 'getDriver', 'getLogs' @@ -28,12 +30,16 @@ class SeleniumHelper { return this.driver.wait(until.elementLocated(By.xpath(xpath), 5 * 1000)); } + findByText (text, scope) { + return this.findByXpath(`//body//${scope || '*'}//*[contains(text(), '${text}')]`); + } + clickXpath (xpath) { return this.findByXpath(xpath).then(el => el.click()); } - clickText (text) { - return this.clickXpath(`//body//*[contains(text(), '${text}')]`); + clickText (text, scope) { + return this.findByText(text, scope).then(el => el.click()); } clickButton (text) { diff --git a/test/integration/examples.test.js b/test/integration/examples.test.js index 3299fb27c532df3838a82dd5cfa04528aa3c287b..7fffa903a4bb6f99f08ab18285869ce7bbd05f17 100644 --- a/test/integration/examples.test.js +++ b/test/integration/examples.test.js @@ -67,12 +67,12 @@ describe('blocks example', () => { await driver.get(`file://${uri}`); await clickText('Looks'); await clickText('Sound'); - await clickText('Pen'); await clickText('Events'); await clickText('Control'); await clickText('Sensing'); await clickText('Operators'); await clickText('Data'); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation await clickText('Create variable...'); let el = await findByXpath("//input[@placeholder='']"); await el.sendKeys('score'); diff --git a/test/integration/test.js b/test/integration/test.js index 39e8c6faec5a2451e08fb0133a0dd34462738fb4..86729cf277017b5cb99f9b858514633583564d47 100644 --- a/test/integration/test.js +++ b/test/integration/test.js @@ -5,6 +5,7 @@ const { clickText, clickButton, clickXpath, + findByText, findByXpath, getDriver, getLogs @@ -18,6 +19,12 @@ const errorWhitelist = [ let driver; +const blocksTabScope = "*[@id='react-tabs-1']"; +const costumesTabScope = "*[@id='react-tabs-3']"; +const soundsTabScope = "*[@id='react-tabs-5']"; +const reportedValueScope = '*[@class="blocklyDropDownContent"]'; +const modalScope = '*[@class="ReactModalPortal"]'; + describe('costumes, sounds and variables', () => { beforeAll(() => { driver = getDriver(); @@ -27,6 +34,31 @@ describe('costumes, sounds and variables', () => { await driver.quit(); }); + + test('Blocks report when clicked in the toolbox', async () => { + await driver.get(`file://${uri}`); + await clickText('Blocks'); + await clickText('Operators', blocksTabScope); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation + await clickText('join', blocksTabScope); // Click "join <hello> <world>" block + await findByText('helloworld', reportedValueScope); // Tooltip with result + const logs = await getLogs(errorWhitelist); + await expect(logs).toEqual([]); + }); + + test('Switching sprites updates the block menus', async () => { + await driver.get(`file://${uri}`); + await clickText('Sound', blocksTabScope); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation + // "meow" sound block should be visible + await findByText('meow', blocksTabScope); + await clickText('Backdrops'); // Switch to the backdrop + // Now "pop" sound block should be visible + await findByText('pop', blocksTabScope); + const logs = await getLogs(errorWhitelist); + await expect(logs).toEqual([]); + }); + test('Adding a costume', async () => { await driver.get(`file://${uri}`); await clickText('Costumes'); @@ -34,8 +66,8 @@ describe('costumes, sounds and variables', () => { const el = await findByXpath("//input[@placeholder='what are you looking for?']"); await el.sendKeys('abb'); await clickText('abby-a'); // Should close the modal, then click the costumes in the selector - await clickText('costume1'); - await clickText('abby-a'); + await clickText('costume1', costumesTabScope); + await clickText('abby-a', costumesTabScope); const logs = await getLogs(errorWhitelist); await expect(logs).toEqual([]); }); @@ -47,10 +79,10 @@ describe('costumes, sounds and variables', () => { const el = await findByXpath("//input[@placeholder='what are you looking for?']"); await el.sendKeys('chom'); await clickText('chomp'); // Should close the modal, then click the sounds in the selector - await clickText('meow'); - await clickText('chomp'); + await clickText('meow', soundsTabScope); + await clickText('chomp', soundsTabScope); await clickXpath('//button[@title="Play"]'); - await clickText('meow'); + await clickText('meow', soundsTabScope); await clickXpath('//button[@title="Play"]'); await clickText('Louder'); @@ -79,7 +111,8 @@ describe('costumes, sounds and variables', () => { test('Creating variables', async () => { await driver.get(`file://${uri}`); await clickText('Blocks'); - await clickText('Data'); + await clickText('Data', blocksTabScope); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation await clickText('Create variable...'); let el = await findByXpath("//input[@placeholder='']"); await el.sendKeys('score'); @@ -88,6 +121,33 @@ describe('costumes, sounds and variables', () => { el = await findByXpath("//input[@placeholder='']"); await el.sendKeys('second variable'); await clickButton('OK'); + + // Make sure reporting works on a new variable + await clickText('Data', blocksTabScope); + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation + await clickText('score', blocksTabScope); + await findByText('0', reportedValueScope); // Tooltip with result + + const logs = await getLogs(errorWhitelist); + await expect(logs).toEqual([]); + }); + + test('Importing extensions', async () => { + await driver.get(`file://${uri}`); + await clickText('Blocks'); + await clickText('Extensions'); + await clickText('Pen', modalScope); // Modal closes + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation + await clickText('stamp', blocksTabScope); // Click the "stamp" block + + // Make sure trying to load the extension again scrolls back down + await clickText('Motion', blocksTabScope); // To scroll the list back to the top + await clickText('Extensions'); + await clickText('Pen', modalScope); // Modal closes + await new Promise(resolve => setTimeout(resolve, 1000)); // Wait for scroll animation + await clickText('stamp', blocksTabScope); // Would fail if didn't scroll back + + const logs = await getLogs(errorWhitelist); await expect(logs).toEqual([]); }); diff --git a/test/unit/containers/sound-editor.test.jsx b/test/unit/containers/sound-editor.test.jsx index 325488a250249f9a426926fb56ab367b519b4cae..63199ef01e6f6fffebd49ea2cb0397d1dbb47887 100644 --- a/test/unit/containers/sound-editor.test.jsx +++ b/test/unit/containers/sound-editor.test.jsx @@ -63,7 +63,7 @@ describe('Sound Editor Container', () => { store={store} /> ); - const component = wrapper.find(SoundEditorComponent); + let component = wrapper.find(SoundEditorComponent); // Ensure rendering doesn't start playing any sounds expect(mockAudioBufferPlayer.instance.play.mock.calls).toEqual([]); expect(mockAudioBufferPlayer.instance.stop.mock.calls).toEqual([]); @@ -73,9 +73,13 @@ describe('Sound Editor Container', () => { // Mock the audio buffer player calling onUpdate mockAudioBufferPlayer.instance.onUpdate(0.5); + wrapper.update(); + component = wrapper.find(SoundEditorComponent); expect(component.props().playhead).toEqual(0.5); component.props().onStop(); + wrapper.update(); + component = wrapper.find(SoundEditorComponent); expect(mockAudioBufferPlayer.instance.stop).toHaveBeenCalled(); expect(component.props().playhead).toEqual(null); }); @@ -87,13 +91,17 @@ describe('Sound Editor Container', () => { store={store} /> ); - const component = wrapper.find(SoundEditorComponent); + let component = wrapper.find(SoundEditorComponent); component.props().onActivateTrim(); + wrapper.update(); + component = wrapper.find(SoundEditorComponent); expect(component.props().trimStart).not.toEqual(null); expect(component.props().trimEnd).not.toEqual(null); component.props().onActivateTrim(); + wrapper.update(); + component = wrapper.find(SoundEditorComponent); expect(vm.updateSoundBuffer).toHaveBeenCalled(); expect(component.props().trimStart).toEqual(null); expect(component.props().trimEnd).toEqual(null); @@ -223,7 +231,7 @@ describe('Sound Editor Container', () => { store={store} /> ); - const component = wrapper.find(SoundEditorComponent); + let component = wrapper.find(SoundEditorComponent); // Undo and redo should be disabled initially expect(component.prop('canUndo')).toEqual(false); expect(component.prop('canRedo')).toEqual(false); @@ -232,27 +240,34 @@ describe('Sound Editor Container', () => { component.props().onActivateTrim(); // Activate trimming component.props().onActivateTrim(); // Submit new samples by calling again wrapper.update(); + component = wrapper.find(SoundEditorComponent); expect(component.prop('canUndo')).toEqual(true); expect(component.prop('canRedo')).toEqual(false); // Undoing should make it possible to redo and not possible to undo again component.props().onUndo(); wrapper.update(); + component = wrapper.find(SoundEditorComponent); expect(component.prop('canUndo')).toEqual(false); expect(component.prop('canRedo')).toEqual(true); // Redoing should make it possible to undo and not possible to redo again component.props().onRedo(); wrapper.update(); + component = wrapper.find(SoundEditorComponent); expect(component.prop('canUndo')).toEqual(true); expect(component.prop('canRedo')).toEqual(false); // New submission should clear the redo stack component.props().onUndo(); // Undo to go back to a state where redo is enabled wrapper.update(); + component = wrapper.find(SoundEditorComponent); expect(component.prop('canRedo')).toEqual(true); component.props().onActivateTrim(); // Activate trimming component.props().onActivateTrim(); // Submit new samples by calling again + + wrapper.update(); + component = wrapper.find(SoundEditorComponent); expect(component.prop('canRedo')).toEqual(false); }); }); diff --git a/test/unit/util/project-loader-hoc.test.jsx b/test/unit/util/project-loader-hoc.test.jsx index 964894c4369441e328e6e365f97631d3736f15b6..3771f37692563ebef894a719138ece5fdc4813b0 100644 --- a/test/unit/util/project-loader-hoc.test.jsx +++ b/test/unit/util/project-loader-hoc.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; -import ProjectLoaderHOC, {ProjectLoader} from '../../../src/lib/project-loader-hoc.jsx'; +import ProjectLoaderHOC from '../../../src/lib/project-loader-hoc.jsx'; +import storage from '../../../src/lib/storage'; import {mount} from 'enzyme'; describe('ProjectLoaderHOC', () => { @@ -7,34 +8,41 @@ describe('ProjectLoaderHOC', () => { const Component = ({projectData}) => <div>{projectData}</div>; const WrappedComponent = ProjectLoaderHOC(Component); window.location.hash = '#winning'; - ProjectLoader.load = jest.fn((id, cb) => cb(null, null)); + const originalLoad = storage.load; + storage.load = jest.fn(() => Promise.resolve(null)); const mounted = mount(<WrappedComponent />); - ProjectLoader.load.mockRestore(); + storage.load = originalLoad; window.location.hash = ''; - expect(mounted.find('div').exists()).toEqual(false); + const mountedDiv = mounted.find('div'); + expect(mountedDiv.exists()).toEqual(false); }); test('when there is no hash, it loads the default project', () => { const Component = ({projectData}) => <div>{projectData}</div>; const WrappedComponent = ProjectLoaderHOC(Component); window.location.hash = ''; + const originalLoad = storage.load; + storage.load = jest.fn((type, id) => Promise.resolve(id)); const mounted = mount(<WrappedComponent />); - expect(mounted.find('div').text()).toEqual(JSON.stringify(ProjectLoader.DEFAULT_PROJECT_DATA)); + expect(mounted.state().projectId).toEqual(0); + expect(storage.load).toHaveBeenCalledWith( + storage.AssetType.Project, 0, storage.DataFormat.JSON + ); + storage.load = originalLoad; }); test('when there is a hash, it tries to load that project', () => { const Component = ({projectData}) => <div>{projectData}</div>; const WrappedComponent = ProjectLoaderHOC(Component); window.location.hash = '#winning'; - ProjectLoader.load = jest.fn((id, cb) => cb(null, id)); + const originalLoad = storage.load; + storage.load = jest.fn((type, id) => Promise.resolve({data: id})); const mounted = mount(<WrappedComponent />); - mounted.update(); - ProjectLoader.load.mockRestore(); - window.location.hash = ''; - expect(mounted - .find('div') - .text() - ).toEqual('winning'); + expect(mounted.state().projectId).toEqual('winning'); + expect(storage.load).toHaveBeenLastCalledWith( + storage.AssetType.Project, 'winning', storage.DataFormat.JSON + ); + storage.load = originalLoad; }); test('when hash change happens, the project data state is changed', () => { @@ -42,10 +50,12 @@ describe('ProjectLoaderHOC', () => { const WrappedComponent = ProjectLoaderHOC(Component); window.location.hash = ''; const mounted = mount(<WrappedComponent />); - const before = mounted.find('div').text(); - ProjectLoader.load = jest.fn((id, cb) => cb(null, id)); - window.location.hash = `#winning`; - mounted.node.updateProject(); - expect(mounted.find('div').text()).not.toEqual(before); + expect(mounted.state().projectId).toEqual(0); + const originalLoad = storage.load; + storage.load = jest.fn((type, id) => Promise.resolve({data: id})); + window.location.hash = '#winning'; + mounted.instance().updateProject(); + expect(mounted.state().projectId).toEqual('winning'); + storage.load = originalLoad; }); }); diff --git a/webpack.config.js b/webpack.config.js index 6c486cc8aaacdf7336c262e602ceaadb47c5fdff..e9b97f9efa740c41a3abdbd0128f7260ad77bf48 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -21,6 +21,7 @@ module.exports = { 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: { @@ -66,8 +67,8 @@ module.exports = { }] }, { - test: /\.svg$/, - loader: 'svg-url-loader?noquotes' + test: /\.(svg|png|wav)$/, + loader: 'file-loader' }] }, plugins: [ @@ -90,6 +91,12 @@ module.exports = { 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',