diff --git a/package.json b/package.json index 690963a1d39f80f3e8d5b2589acf3ac956f7b875..13a4b8c1f0dd219702d475c71207e4b18c4dfd87 100644 --- a/package.json +++ b/package.json @@ -38,16 +38,18 @@ "babel-plugin-transform-object-rest-spread": "^6.22.0", "babel-preset-es2015": "^6.22.0", "babel-preset-react": "^6.22.0", + "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": "^3.1.0", - "enzyme-adapter-react-16": "1.0.2", + "enzyme-adapter-react-16": "1.0.3", "eslint": "^4.7.1", "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", @@ -66,6 +68,7 @@ "postcss-simple-vars": "^4.0.0", "prop-types": "^15.5.10", "raf": "^3.4.0", + "raw-loader": "0.5.1", "react": "16.0.0", "react-contextmenu": "2.8.0", "react-dom": "16.0.0", @@ -83,17 +86,17 @@ "redux-throttle": "0.1.1", "rimraf": "^2.6.1", "scratch-audio": "latest", - "scratch-paint": "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", "startaudiocontext": "1.2.1", "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", 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/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 9ee7b5f4e10ebb27a27898be7b91fd2c52469271..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, @@ -40,7 +38,6 @@ const TargetPane = ({ onRequestCloseCostumeLibrary, onRequestCloseSoundLibrary, onRequestCloseSpriteLibrary, - onRequestCloseExtensionLibrary, onSelectSprite, stage, sprites, @@ -78,12 +75,6 @@ const TargetPane = ({ onSelect={onSelectSprite} />} <div> - {extensionLibraryVisible ? ( - <ExtensionLibrary - vm={vm} - onRequestClose={onRequestCloseExtensionLibrary} - /> - ) : null} {spriteLibraryVisible ? ( <SpriteLibrary vm={vm} diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 345d21e26f5a7d4e79f5a336b930be989c51bc42..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,7 +73,8 @@ 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) { @@ -87,7 +92,6 @@ class Blocks extends React.Component { this.workspace.setVisible(true); this.props.vm.refreshWorkspace(); window.dispatchEvent(new Event('resize')); - this.workspace.toolbox_.refreshSelection(); } else { this.workspace.setVisible(false); } @@ -172,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]; @@ -193,7 +195,8 @@ class Blocks extends React.Component { const dynamicBlocksXML = this.props.vm.runtime.getBlocksXML(); const toolboxXML = makeToolboxXML(dynamicBlocksXML); this.props.onExtensionAdded(toolboxXML); - const categoryName = blocksInfo[0].json.category; + } + handleCategorySelected (categoryName) { this.workspace.toolbox_.setSelectedCategoryByName(categoryName); } setBlocks (blocks) { @@ -212,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; @@ -236,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({ @@ -302,6 +316,7 @@ Blocks.defaultProps = { }; const mapStateToProps = state => ({ + extensionLibraryVisible: state.modals.extensionLibrary, toolboxXML: state.toolbox.toolboxXML }); @@ -309,6 +324,9 @@ const mapDispatchToProps = dispatch => ({ onActivateColorPicker: callback => dispatch(activateColorPicker(callback)), onExtensionAdded: toolboxXML => { dispatch(updateToolbox(toolboxXML)); + }, + onRequestCloseExtensionLibrary: () => { + dispatch(closeExtensionLibrary()); } }); 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 index ca033c06170577fe3fb2f4db76e66ac8a2d45628..6448f3e9e2b8901f392517728ccac4f8bdc74190 100644 --- a/src/containers/paint-editor-wrapper.jsx +++ b/src/containers/paint-editor-wrapper.jsx @@ -55,7 +55,7 @@ const mapStateToProps = (state, {selectedCostumeIndex}) => { name: costume && costume.name, rotationCenterX: costume && costume.rotationCenterX, rotationCenterY: costume && costume.rotationCenterY, - svgId: editingTarget && `${editingTarget}${selectedCostumeIndex}` + svgId: editingTarget && `${editingTarget}${costume.skinId}` }; }; diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx index 3a68847c657d15eae94475e5efdc7923b68a1d48..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'; @@ -97,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 => { @@ -111,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 80edbcb55d70494600cc79ed4f58a76fc38b7b60..0000000000000000000000000000000000000000 --- a/src/lib/libraries/extensions.json +++ /dev/null @@ -1,9 +0,0 @@ -[ - { - "name": "Pen", - "extensionURL": "pen", - "md5": "1a2ec605e73000897797f0c851134f5b.png", - "description": "Draw with your sprites.", - "featured": true - } -] 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 174319ec4dc0a235667c14ffffc4d28bf12e87df..c822ef332c1ac69ca98a39daaa551a238c055c89 100644 --- a/src/lib/make-toolbox-xml.js +++ b/src/lib/make-toolbox-xml.js @@ -1,78 +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_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_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"> @@ -97,6 +25,7 @@ const motion = ` </shadow> </value> </block> + ${blockSeparator} <block type="motion_pointindirection"> <value name="DIRECTION"> <shadow type="math_angle"> @@ -110,6 +39,7 @@ const motion = ` </shadow> </value> </block> + ${blockSeparator} <block type="motion_gotoxy"> <value name="X"> <shadow id="movex" type="math_number"> @@ -156,6 +86,7 @@ const motion = ` </shadow> </value> </block> + ${blockSeparator} <block type="motion_changexby"> <value name="DX"> <shadow type="math_number"> @@ -184,11 +115,15 @@ const motion = ` </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> `; @@ -232,8 +167,10 @@ const looks = ` </shadow> </value> </block> + ${blockSeparator} <block type="looks_show"/> <block type="looks_hide"/> + ${blockSeparator} <block type="looks_switchcostumeto"> <value name="COSTUME"> <shadow type="looks_costume"/> @@ -251,6 +188,7 @@ const looks = ` <shadow type="looks_backdrops"/> </value> </block> + ${blockSeparator} <block type="looks_changeeffectby"> <value name="CHANGE"> <shadow type="math_number"> @@ -266,6 +204,7 @@ const looks = ` </value> </block> <block type="looks_cleargraphiceffects"/> + ${blockSeparator} <block type="looks_changesizeby"> <value name="CHANGE"> <shadow type="math_number"> @@ -280,6 +219,7 @@ const looks = ` </shadow> </value> </block> + ${blockSeparator} <block type="looks_gotofront"/> <block type="looks_gobacklayers"> <value name="NUM"> @@ -288,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> `; @@ -308,6 +250,7 @@ const sound = ` </value> </block> <block type="sound_stopallsounds"/> + ${blockSeparator} <block type="sound_playdrumforbeats"> <value name="DRUM"> <shadow type="sound_drums_menu"/> @@ -325,6 +268,7 @@ const sound = ` </shadow> </value> </block> + ${blockSeparator} <block type="sound_playnoteforbeats"> <value name="NOTE"> <shadow type="math_number"> @@ -342,6 +286,7 @@ const sound = ` <shadow type="sound_instruments_menu"/> </value> </block> + ${blockSeparator} <block type="sound_changeeffectby"> <value name="VALUE"> <shadow type="math_number"> @@ -357,6 +302,7 @@ const sound = ` </value> </block> <block type="sound_cleareffects"/> + ${blockSeparator} <block type="sound_changevolumeby"> <value name="VOLUME"> <shadow type="math_number"> @@ -371,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"> @@ -386,7 +333,8 @@ const sound = ` </shadow> </value> </block> - <block type="sound_tempo"/> + <block id="tempo" type="sound_tempo"/> + ${categorySeparator} </category> `; @@ -398,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"> @@ -405,6 +354,7 @@ const events = ` </shadow> </value> </block> + ${blockSeparator} <block type="event_whenbroadcastreceived"> </block> <block type="event_broadcast"> @@ -417,6 +367,7 @@ const events = ` <shadow type="event_broadcast_menu"/> </value> </block> + ${categorySeparator} </category> `; @@ -429,6 +380,7 @@ const control = ` </shadow> </value> </block> + ${blockSeparator} <block type="control_repeat"> <value name="TIMES"> <shadow type="math_whole_number"> @@ -437,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"> @@ -449,6 +404,7 @@ const control = ` </value> </block> <block type="control_delete_this_clone"/> + ${categorySeparator} </category> `; @@ -477,6 +433,7 @@ const sensing = ` <shadow type="sensing_distancetomenu"/> </value> </block> + ${blockSeparator} <block type="sensing_askandwait"> <value name="QUESTION"> <shadow type="text"> @@ -484,7 +441,8 @@ const sensing = ` </shadow> </value> </block> - <block type="sensing_answer"/> + <block id="answer" type="sensing_answer"/> + ${blockSeparator} <block type="sensing_keypressed"> <value name="KEY_OPTION"> <shadow type="sensing_keyoptions"/> @@ -493,23 +451,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> `; @@ -563,6 +526,7 @@ const operators = ` </shadow> </value> </block> + ${blockSeparator} <block type="operator_random"> <value name="FROM"> <shadow type="math_number"> @@ -575,6 +539,7 @@ const operators = ` </shadow> </value> </block> + ${blockSeparator} <block type="operator_lt"> <value name="OPERAND1"> <shadow type="text"> @@ -611,9 +576,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"> @@ -645,6 +612,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"> @@ -664,6 +644,7 @@ const operators = ` </shadow> </value> </block> + ${blockSeparator} <block type="operator_mathop"> <value name="NUM"> <shadow type="math_number"> @@ -671,6 +652,7 @@ const operators = ` </shadow> </value> </block> + ${categorySeparator} </category> `; @@ -687,11 +669,10 @@ 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, @@ -707,7 +688,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/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/integration/examples.test.js b/test/integration/examples.test.js index e2b0c8ab41827803c4ad7b08d216416e84d20053..7fffa903a4bb6f99f08ab18285869ce7bbd05f17 100644 --- a/test/integration/examples.test.js +++ b/test/integration/examples.test.js @@ -72,6 +72,7 @@ describe('blocks example', () => { 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 efd0732f2ed9e1b7700e2a4f859aa25148028917..86729cf277017b5cb99f9b858514633583564d47 100644 --- a/test/integration/test.js +++ b/test/integration/test.js @@ -23,6 +23,7 @@ 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(() => { @@ -135,9 +136,18 @@ describe('costumes, sounds and variables', () => { await driver.get(`file://${uri}`); await clickText('Blocks'); await clickText('Extensions'); - await clickText('Pen'); // Modal closes - await clickText('Pen', blocksTabScope); // Click the new category + 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/util/project-loader-hoc.test.jsx b/test/unit/util/project-loader-hoc.test.jsx index d61352d3630c51a81c5da5d612da89ab65038a2a..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`; + 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.find('div').text()).not.toEqual(before); + 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',