diff --git a/.travis.yml b/.travis.yml index ec6ccee6c..33c4de439 100644 --- a/.travis.yml +++ b/.travis.yml @@ -18,6 +18,7 @@ install: - npm --production=false install - npm --production=false update before_deploy: +- npm --no-git-tag-version version 0.1.0-prerelease.$(date +%Y%m%d%H%M%S) - git config --global user.email $(git log --pretty=format:"%ae" -n1) - git config --global user.name $(git log --pretty=format:"%an" -n1) deploy: @@ -32,3 +33,10 @@ deploy: branch: master skip_cleanup: true script: npm run --silent deploy -- -x -a -r https://${GH_TOKEN}@github.com/${TRAVIS_REPO_SLUG}.git +- provider: npm + on: + branch: master + skip_cleanup: true + email: $NPM_EMAIL + api_key: $NPM_TOKEN + diff --git a/package.json b/package.json index ce1f5108b..3bd394669 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,8 @@ "lint": "eslint . --ext .js,.jsx", "start": "npm run i18n:msgs && webpack-dev-server", "unit-test": "jest test/unit", - "integration-test": "npm run build && jest test/integration", - "test": "npm run lint && npm run unit-test && npm run integration-test", + "integration-test": "jest --runInBand test/integration", + "test": "npm run lint && npm run unit-test && npm run build", "watch": "webpack --progress --colors --watch" }, "author": "Massachusetts Institute of Technology", @@ -28,31 +28,31 @@ "react-dom": "^15.6.1" }, "devDependencies": { - "autoprefixer": "7.1.2", - "babel-cli": "6.26.0", + "autoprefixer": "^7.1.3", + "babel-cli": "^6.26.0", "babel-core": "^6.23.1", "babel-eslint": "^7.2.3", "babel-loader": "^7.1.0", - "babel-plugin-react-intl": "2.3.1", - "babel-plugin-syntax-dynamic-import": "6.18.0", + "babel-plugin-react-intl": "^2.3.1", + "babel-plugin-syntax-dynamic-import": "^6.18.0", "babel-plugin-transform-async-to-generator": "^6.24.1", "babel-plugin-transform-object-rest-spread": "^6.22.0", "babel-preset-es2015": "^6.22.0", "babel-preset-react": "^6.22.0", - "chromedriver": "^2.31.0", + "chromedriver": "^2.32.3", "classnames": "2.2.5", - "copy-webpack-plugin": "4.0.1", - "css-loader": "0.28.5", + "copy-webpack-plugin": "^4.0.1", + "css-loader": "^0.28.7", "enzyme": "^2.8.2", - "eslint": "^4.4.1", + "eslint": "^4.7.1", "eslint-config-scratch": "^4.0.0", "eslint-plugin-import": "^2.7.0", "eslint-plugin-react": "^7.2.1", "get-float-time-domain-data": "0.1.0", "gh-pages": "github:rschamp/gh-pages#publish-branch-to-subfolder", - "html-webpack-plugin": "2.30.0", + "html-webpack-plugin": "^2.30.0", "immutable": "3.8.1", - "jest": "^20.0.4", + "jest": "^21.0.0", "lodash.bindall": "4.4.0", "lodash.debounce": "4.0.8", "lodash.defaultsdeep": "4.6.0", @@ -66,16 +66,16 @@ "postcss-simple-vars": "^4.0.0", "prop-types": "^15.5.10", "react": "15.6.1", - "react-contextmenu": "2.6.5", + "react-contextmenu": "2.7.0", "react-dom": "15.6.1", - "react-draggable": "2.2.6", - "react-intl": "2.3.0", + "react-draggable": "3.0.3", + "react-intl": "2.4.0", "react-intl-redux": "0.6.0", - "react-modal": "2.2.4", + "react-modal": "2.3.1", "react-redux": "5.0.6", "react-responsive": "1.3.4", "react-style-proptype": "3.0.0", - "react-tabs": "1.1.0", + "react-tabs": "2.0.0", "react-test-renderer": "^15.5.4", "redux": "3.7.0", "redux-mock-store": "^1.2.3", @@ -90,11 +90,11 @@ "startaudiocontext": "1.2.1", "style-loader": "^0.18.0", "svg-to-image": "1.1.3", - "svg-url-loader": "2.1.0", + "svg-url-loader": "^2.1.0", "wav-encoder": "1.3.0", "web-audio-test-api": "^0.5.2", - "webpack": "^3.5.4", - "webpack-dev-server": "^2.7.0", + "webpack": "^3.6.0", + "webpack-dev-server": "^2.8.2", "xhr": "2.4.0" }, "jest": { diff --git a/src/components/blocks/blocks.css b/src/components/blocks/blocks.css index 80689e48c..6c5858efd 100644 --- a/src/components/blocks/blocks.css +++ b/src/components/blocks/blocks.css @@ -21,6 +21,7 @@ $border-style: 1px solid $ui-pane-border; border-right: $border-style; border-bottom: $border-style; box-sizing: content-box; + height: calc(100% - 3.25rem) !important; } .blocks :global(.blocklyFlyout) { diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index 6412c9a88..bb336c40d 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -148,3 +148,24 @@ height: $stage-menu-height; padding: $space; } + +.extension-button-container { + width: 60px; + height: 3.25rem; + position: absolute; + bottom: 0; + left: 0; + z-index: 50; /** Force extension button above the ScratchBlocks flyout. */ + background: white; + + border: 1px solid #ddd; + box-sizing: content-box; /* To match scratch-block vertical toolbox borders */ +} + +.extension-button { + font-size: 0.55rem; + font-weight: bold; +} +.extension-button > div { + margin-top: 0; +} diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 1eb0e6505..2257c2cee 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -12,18 +12,31 @@ import Controls from '../../containers/controls.jsx'; import TargetPane from '../../containers/target-pane.jsx'; import SoundTab from '../../containers/sound-tab.jsx'; import Stage from '../../containers/stage.jsx'; +import {FormattedMessage} from 'react-intl'; import Box from '../box/box.jsx'; +import IconButton from '../icon-button/icon-button.jsx'; import MenuBar from '../menu-bar/menu-bar.jsx'; import layout from '../../lib/layout-constants.js'; import styles from './gui.css'; +import addExtensionIcon from './icon--extensions.svg'; + +const addExtensionMessage = ( + +); const GUIComponent = props => { const { basePath, children, + enableExtensions, vm, + onExtensionButtonClick, onTabSelect, tabIndex, ...componentProps @@ -77,12 +90,22 @@ const GUIComponent = props => { vm={vm} /> + + + - + {tabIndex === 1 ? : null} - + {tabIndex === 2 ? : null} @@ -115,6 +138,8 @@ const GUIComponent = props => { GUIComponent.propTypes = { basePath: PropTypes.string, children: PropTypes.node, + enableExtensions: PropTypes.bool, + onExtensionButtonClick: PropTypes.func, onTabSelect: PropTypes.func, tabIndex: PropTypes.number, vm: PropTypes.instanceOf(VM).isRequired diff --git a/src/components/gui/icon--extensions.svg b/src/components/gui/icon--extensions.svg new file mode 100644 index 000000000..de96e820c --- /dev/null +++ b/src/components/gui/icon--extensions.svg @@ -0,0 +1,18 @@ + + + + add extension + Created with Sketch. + + + + + + + + + + + + + diff --git a/src/components/library/library.jsx b/src/components/library/library.jsx index f5c0ae30c..165716e5b 100644 --- a/src/components/library/library.jsx +++ b/src/components/library/library.jsx @@ -43,13 +43,11 @@ class LibraryComponent extends React.Component { dataItem.name.toLowerCase().indexOf(this.state.filterQuery.toLowerCase()) !== -1); } render () { - if (!this.props.visible) return null; return ( + ); + } +} + +LoupeComponent.propTypes = { + colorInfo: PropTypes.shape({ + color: PropTypes.shape({ + r: PropTypes.number, + g: PropTypes.number, + b: PropTypes.number + }), + data: PropTypes.instanceOf(Uint8Array), + width: PropTypes.number, + height: PropTypes.number, + x: PropTypes.number, + y: PropTypes.number + }) +}; + +export default LoupeComponent; diff --git a/src/components/modal/modal.jsx b/src/components/modal/modal.jsx index 4b2711f51..3573e684c 100644 --- a/src/components/modal/modal.jsx +++ b/src/components/modal/modal.jsx @@ -13,9 +13,9 @@ class ModalComponent extends React.Component { render () { return ( (this.modal = m)} onRequestClose={this.props.onRequestClose} @@ -68,8 +68,7 @@ ModalComponent.propTypes = { filterQuery: PropTypes.string, onFilterChange: PropTypes.func, onFilterClear: PropTypes.func, - onRequestClose: PropTypes.func, - visible: PropTypes.bool.isRequired + onRequestClose: PropTypes.func }; export default ModalComponent; diff --git a/src/components/prompt/prompt.jsx b/src/components/prompt/prompt.jsx index d43002b41..4fca4cb4a 100644 --- a/src/components/prompt/prompt.jsx +++ b/src/components/prompt/prompt.jsx @@ -7,7 +7,6 @@ import styles from './prompt.css'; const PromptComponent = props => ( ( { canvasRef, width, height, + colorInfo, + onDeactivateColorPicker, + isColorPicking, ...boxProps } = props; return ( - +
- - + className={classNames(styles.stageWrapper, { + [styles.withColorPicker]: isColorPicking + })} + > + + + + + {isColorPicking && colorInfo ? ( + + + + ) : null} - + {isColorPicking ? ( + + ) : null} +
); }; StageComponent.propTypes = { canvasRef: PropTypes.func, + colorInfo: Loupe.propTypes.colorInfo, height: PropTypes.number, + isColorPicking: PropTypes.bool, + onDeactivateColorPicker: PropTypes.func, width: PropTypes.number }; StageComponent.defaultProps = { diff --git a/src/components/target-pane/target-pane.jsx b/src/components/target-pane/target-pane.jsx index a88a994a0..07281756d 100644 --- a/src/components/target-pane/target-pane.jsx +++ b/src/components/target-pane/target-pane.jsx @@ -3,11 +3,12 @@ import React from 'react'; import VM from 'scratch-vm'; -import Box from '../box/box.jsx'; 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'; @@ -21,6 +22,7 @@ import styles from './target-pane.css'; */ const TargetPane = ({ editingTarget, + extensionLibraryVisible, backdropLibraryVisible, costumeLibraryVisible, soundLibraryVisible, @@ -37,13 +39,14 @@ const TargetPane = ({ onRequestCloseCostumeLibrary, onRequestCloseSoundLibrary, onRequestCloseSpriteLibrary, + onRequestCloseExtensionLibrary, onSelectSprite, stage, sprites, vm, ...componentProps }) => ( - @@ -61,7 +64,7 @@ const TargetPane = ({ onNewSpriteClick={onNewSpriteClick} onSelectSprite={onSelectSprite} /> - +
{stage.id && } - - - - - - - - +
+ {extensionLibraryVisible ? ( + + ) : null} + {spriteLibraryVisible ? ( + + ) : null} + {costumeLibraryVisible ? ( + + ) : null} + {soundLibraryVisible ? ( + + ) : null} + {backdropLibraryVisible ? ( + + ) : null} +
+
+ ); const spriteShape = PropTypes.shape({ @@ -120,6 +133,7 @@ TargetPane.propTypes = { backdropLibraryVisible: PropTypes.bool, costumeLibraryVisible: PropTypes.bool, editingTarget: PropTypes.string, + extensionLibraryVisible: PropTypes.bool, onChangeSpriteDirection: PropTypes.func, onChangeSpriteName: PropTypes.func, onChangeSpriteRotationStyle: PropTypes.func, @@ -130,6 +144,7 @@ TargetPane.propTypes = { onNewSpriteClick: PropTypes.func, onRequestCloseBackdropLibrary: PropTypes.func, onRequestCloseCostumeLibrary: PropTypes.func, + onRequestCloseExtensionLibrary: PropTypes.func, onRequestCloseSoundLibrary: PropTypes.func, onRequestCloseSpriteLibrary: PropTypes.func, onSelectSprite: PropTypes.func, diff --git a/src/containers/audio-trimmer.jsx b/src/containers/audio-trimmer.jsx index f4ba1be3c..9cf6e38f1 100644 --- a/src/containers/audio-trimmer.jsx +++ b/src/containers/audio-trimmer.jsx @@ -4,6 +4,8 @@ import bindAll from 'lodash.bindall'; import AudioTrimmerComponent from '../components/audio-trimmer/audio-trimmer.jsx'; import {getEventXY} from '../lib/touch-utils'; +const MIN_LENGTH = 0.01; // Used to stop sounds being trimmed smaller than 1% + class AudioTrimmer extends React.Component { constructor (props) { super(props); @@ -20,14 +22,14 @@ class AudioTrimmer extends React.Component { handleTrimStartMouseMove (e) { const containerSize = this.containerElement.getBoundingClientRect().width; const dx = (getEventXY(e).x - this.initialX) / containerSize; - const newTrim = Math.max(0, Math.min(this.props.trimEnd, this.initialTrim + dx)); + const newTrim = Math.max(0, Math.min(this.props.trimEnd - MIN_LENGTH, this.initialTrim + dx)); this.props.onSetTrimStart(newTrim); e.preventDefault(); } handleTrimEndMouseMove (e) { const containerSize = this.containerElement.getBoundingClientRect().width; const dx = (getEventXY(e).x - this.initialX) / containerSize; - const newTrim = Math.min(1, Math.max(this.props.trimStart, this.initialTrim + dx)); + const newTrim = Math.min(1, Math.max(this.props.trimStart + MIN_LENGTH, this.initialTrim + dx)); this.props.onSetTrimEnd(newTrim); e.preventDefault(); } diff --git a/src/containers/backdrop-library.jsx b/src/containers/backdrop-library.jsx index e455a0a25..21eb3825a 100644 --- a/src/containers/backdrop-library.jsx +++ b/src/containers/backdrop-library.jsx @@ -29,7 +29,6 @@ class BackdropLibrary extends React.Component { @@ -39,7 +38,6 @@ class BackdropLibrary extends React.Component { BackdropLibrary.propTypes = { onRequestClose: PropTypes.func, - visible: PropTypes.bool, vm: PropTypes.instanceOf(VM).isRequired }; diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 63a762765..7a2a4d908 100644 --- a/src/containers/blocks.jsx +++ b/src/containers/blocks.jsx @@ -1,6 +1,7 @@ import bindAll from 'lodash.bindall'; import debounce from 'lodash.debounce'; import defaultsDeep from 'lodash.defaultsdeep'; +import makeToolboxXML from '../lib/make-toolbox-xml'; import PropTypes from 'prop-types'; import React from 'react'; import VMScratchBlocks from '../lib/blocks'; @@ -8,6 +9,10 @@ import VM from 'scratch-vm'; import Prompt from './prompt.jsx'; import BlocksComponent from '../components/blocks/blocks.jsx'; +import {connect} from 'react-redux'; +import {updateToolbox} from '../reducers/toolbox'; +import {activateColorPicker} from '../reducers/color-picker'; + const addFunctionListener = (object, property, callback) => { const oldFn = object[property]; object[property] = function () { @@ -31,6 +36,7 @@ class Blocks extends React.Component { 'onScriptGlowOff', 'onBlockGlowOn', 'onBlockGlowOff', + 'handleExtensionAdded', 'onTargetsUpdate', 'onVisualReport', 'onWorkspaceUpdate', @@ -45,9 +51,14 @@ class Blocks extends React.Component { this.onTargetsUpdate = debounce(this.onTargetsUpdate, 100); } componentDidMount () { + this.ScratchBlocks.FieldColourSlider.activateEyedropper_ = this.props.onActivateColorPicker; + const workspaceConfig = defaultsDeep({}, Blocks.defaultOptions, this.props.options); 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); @@ -55,9 +66,20 @@ class Blocks extends React.Component { this.attachVM(); } shouldComponentUpdate (nextProps, nextState) { - return this.state.prompt !== nextState.prompt || this.props.isVisible !== nextProps.isVisible; + return ( + this.state.prompt !== nextState.prompt || + this.props.isVisible !== nextProps.isVisible || + this.props.toolboxXML !== nextProps.toolboxXML + ); } 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); + } if (this.props.isVisible === prevProps.isVisible) { return; } @@ -77,6 +99,20 @@ 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 @@ -91,6 +127,7 @@ class Blocks extends React.Component { this.props.vm.addListener('VISUAL_REPORT', this.onVisualReport); this.props.vm.addListener('workspaceUpdate', this.onWorkspaceUpdate); this.props.vm.addListener('targetsUpdate', this.onTargetsUpdate); + this.props.vm.addListener('EXTENSION_ADDED', this.handleExtensionAdded); } detachVM () { this.props.vm.removeListener('SCRIPT_GLOW_ON', this.onScriptGlowOn); @@ -100,6 +137,7 @@ class Blocks extends React.Component { this.props.vm.removeListener('VISUAL_REPORT', this.onVisualReport); this.props.vm.removeListener('workspaceUpdate', this.onWorkspaceUpdate); this.props.vm.removeListener('targetsUpdate', this.onTargetsUpdate); + this.props.vm.removeListener('EXTENSION_ADDED', this.handleExtensionAdded); } updateToolboxBlockValue (id, value) { const block = this.workspace @@ -167,6 +205,12 @@ class Blocks extends React.Component { this.workspace.resize(); } } + handleExtensionAdded (blocksInfo) { + this.ScratchBlocks.defineBlocksWithJsonArray(blocksInfo.map(blockInfo => blockInfo.json)); + const dynamicBlocksXML = this.props.vm.runtime.getBlocksXML(); + const toolboxXML = makeToolboxXML(dynamicBlocksXML); + this.props.onExtensionAdded(toolboxXML); + } setBlocks (blocks) { this.blocks = blocks; } @@ -181,12 +225,17 @@ class Blocks extends React.Component { this.setState({prompt: null}); } render () { + /* eslint-disable no-unused-vars */ const { - options, // eslint-disable-line no-unused-vars - vm, // eslint-disable-line no-unused-vars - isVisible, // eslint-disable-line no-unused-vars + options, + vm, + isVisible, + onActivateColorPicker, + onExtensionAdded, + toolboxXML, ...props } = this.props; + /* eslint-enable no-unused-vars */ return (
({ + toolboxXML: state.toolbox.toolboxXML +}); + +const mapDispatchToProps = dispatch => ({ + onActivateColorPicker: callback => dispatch(activateColorPicker(callback)), + onExtensionAdded: toolboxXML => { + dispatch(updateToolbox(toolboxXML)); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Blocks); diff --git a/src/containers/costume-library.jsx b/src/containers/costume-library.jsx index ee5526752..7272bb95d 100644 --- a/src/containers/costume-library.jsx +++ b/src/containers/costume-library.jsx @@ -29,7 +29,6 @@ class CostumeLibrary extends React.PureComponent { @@ -39,7 +38,6 @@ class CostumeLibrary extends React.PureComponent { CostumeLibrary.propTypes = { onRequestClose: PropTypes.func, - visible: PropTypes.bool, vm: PropTypes.instanceOf(VM).isRequired }; diff --git a/src/containers/extension-library.jsx b/src/containers/extension-library.jsx new file mode 100644 index 000000000..343d0747d --- /dev/null +++ b/src/containers/extension-library.jsx @@ -0,0 +1,48 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; +import VM from 'scratch-vm'; + +import extensionLibraryContent from '../lib/libraries/extensions.json'; + +import LibraryComponent from '../components/library/library.jsx'; +import extensionIcon from '../components/sprite-selector/icon--sprite.svg'; + +class ExtensionLibrary extends React.PureComponent { + constructor (props) { + super(props); + bindAll(this, [ + 'handleItemSelect' + ]); + } + handleItemSelect (item) { + // 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); + } + } + render () { + const extensionLibraryThumbnailData = extensionLibraryContent.map(extension => ({ + rawURL: extension.iconURL || extensionIcon, + ...extension + })); + return ( + + ); + } +} + +ExtensionLibrary.propTypes = { + onRequestClose: PropTypes.func, + visible: PropTypes.bool, + vm: PropTypes.instanceOf(VM).isRequired // eslint-disable-line react/no-unused-prop-types +}; + +export default ExtensionLibrary; diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index c41f7a9a9..9de0e3616 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -3,6 +3,9 @@ import PropTypes from 'prop-types'; import React from 'react'; import VM from 'scratch-vm'; import bindAll from 'lodash.bindall'; +import {connect} from 'react-redux'; + +import {openExtensionLibrary} from '../reducers/modals.js'; import vmListenerHOC from '../lib/vm-listener-hoc.jsx'; @@ -43,6 +46,7 @@ class GUI extends React.Component { } = this.props; return ( ({}); + +const mapDispatchToProps = dispatch => ({ + onExtensionButtonClick: () => dispatch(openExtensionLibrary()) +}); + +const ConnectedGUI = connect( + mapStateToProps, + mapDispatchToProps, +)(GUI); + +export default vmListenerHOC(ConnectedGUI); diff --git a/src/containers/record-modal.jsx b/src/containers/record-modal.jsx index fd5647c49..fa619805a 100644 --- a/src/containers/record-modal.jsx +++ b/src/containers/record-modal.jsx @@ -130,7 +130,6 @@ RecordModal.propTypes = { }; const mapStateToProps = state => ({ - visible: state.modals.soundRecorder, vm: state.vm }); diff --git a/src/containers/recording-step.jsx b/src/containers/recording-step.jsx index d1fc3302b..4e82e6ec8 100644 --- a/src/containers/recording-step.jsx +++ b/src/containers/recording-step.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import PropTypes from 'prop-types'; import bindAll from 'lodash.bindall'; import RecordingStepComponent from '../components/record-modal/recording-step.jsx'; import AudioRecorder from '../lib/audio/audio-recorder.js'; @@ -66,6 +67,10 @@ class RecordingStep extends React.Component { } } -RecordingStep.propTypes = RecordingStepComponent.propTypes; +RecordingStep.propTypes = { + onRecord: PropTypes.func.isRequired, + onStopRecording: PropTypes.func.isRequired, + recording: PropTypes.bool +}; export default RecordingStep; diff --git a/src/containers/sound-library.jsx b/src/containers/sound-library.jsx index 8fe3f97bd..3949103bb 100644 --- a/src/containers/sound-library.jsx +++ b/src/containers/sound-library.jsx @@ -22,9 +22,8 @@ class SoundLibrary extends React.PureComponent { this.audioEngine = new AudioEngine(); this.player = this.audioEngine.createPlayer(); } - componentWillReceiveProps (newProps) { - // Stop playing sounds if the library closes without a mouseleave (e.g. by using the escape key) - if (this.player && !newProps.visible) this.player.stopAllSounds(); + componentWillUnmount () { + this.player.stopAllSounds(); } handleItemMouseEnter (soundItem) { const md5ext = soundItem._md5; @@ -76,7 +75,6 @@ class SoundLibrary extends React.PureComponent { - {target.sounds && target.sounds.length > 0 ? ( + {editingTarget && target.sounds && target.sounds.length > 0 ? ( ) : null} {this.props.soundRecorderVisible ? ( diff --git a/src/containers/sprite-library.jsx b/src/containers/sprite-library.jsx index cb1126420..6062e02a9 100644 --- a/src/containers/sprite-library.jsx +++ b/src/containers/sprite-library.jsx @@ -24,8 +24,8 @@ class SpriteLibrary extends React.PureComponent { sprites: spriteLibraryContent }; } - componentWillReceiveProps (newProps) { - if (!newProps.visible) clearInterval(this.intervalId); + componentWillUnmount () { + clearInterval(this.intervalId); } handleItemSelect (item) { this.props.vm.addSprite2(JSON.stringify(item.json)); @@ -66,7 +66,6 @@ class SpriteLibrary extends React.PureComponent { { + this.setState({colorInfo: this.getColorInfo(this.pickX, this.pickY)}); + }, 30); + } + stopColorPickingLoop () { + clearInterval(this.intervalId); } attachMouseEvents (canvas) { document.addEventListener('mousemove', this.onMouseMove); @@ -79,6 +108,13 @@ class Stage extends React.Component { (nativeSize[1] / this.rect.height) * (y - (this.rect.height / 2)) ]; } + getColorInfo (x, y) { + return { + x: x, + y: y, + ...this.renderer.extractColor(x, y, colorPickerRadius) + }; + } handleDoubleClick (e) { const {x, y} = getEventXY(e); // Set editing target from cursor position, if clicking on a sprite. @@ -92,6 +128,11 @@ class Stage extends React.Component { onMouseMove (e) { const {x, y} = getEventXY(e); const mousePosition = [x - this.rect.left, y - this.rect.top]; + + // Set the pickX/Y for the color picker loop to pick up + this.pickX = mousePosition[0]; + this.pickY = mousePosition[1]; + if (this.state.mouseDownTimeoutId !== null) { this.cancelMouseDownTimeout(); if (this.state.mouseDown && !this.state.isDragging) { @@ -157,6 +198,16 @@ class Stage extends React.Component { if (e.preventDefault) { e.preventDefault(); } + if (this.props.isColorPicking) { + const {r, g, b} = this.state.colorInfo.color; + const componentToString = c => { + const hex = c.toString(16); + return hex.length === 1 ? `0${hex}` : hex; + }; + const colorString = `#${componentToString(r)}${componentToString(g)}${componentToString(b)}`; + this.props.onDeactivateColorPicker(colorString); + this.setState({colorInfo: null}); + } } cancelMouseDownTimeout () { if (this.state.mouseDownTimeoutId !== null) { @@ -191,11 +242,13 @@ class Stage extends React.Component { render () { const { vm, // eslint-disable-line no-unused-vars + onActivateColorPicker, // eslint-disable-line no-unused-vars ...props } = this.props; return ( @@ -205,8 +258,23 @@ class Stage extends React.Component { Stage.propTypes = { height: PropTypes.number, + isColorPicking: PropTypes.bool, + onActivateColorPicker: PropTypes.func, + onDeactivateColorPicker: PropTypes.func, vm: PropTypes.instanceOf(VM).isRequired, width: PropTypes.number }; -export default Stage; +const mapStateToProps = state => ({ + isColorPicking: state.colorPicker.active +}); + +const mapDispatchToProps = dispatch => ({ + onActivateColorPicker: () => dispatch(activateColorPicker()), + onDeactivateColorPicker: color => dispatch(deactivateColorPicker(color)) +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Stage); diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx index 9b70a79d4..0ec1c43ff 100644 --- a/src/containers/target-pane.jsx +++ b/src/containers/target-pane.jsx @@ -7,6 +7,7 @@ import { openSpriteLibrary, closeBackdropLibrary, closeCostumeLibrary, + closeExtensionLibrary, closeSoundLibrary, closeSpriteLibrary } from '../reducers/modals'; @@ -91,7 +92,8 @@ const mapStateToProps = state => ({ soundLibraryVisible: state.modals.soundLibrary, spriteLibraryVisible: state.modals.spriteLibrary, costumeLibraryVisible: state.modals.costumeLibrary, - backdropLibraryVisible: state.modals.backdropLibrary + backdropLibraryVisible: state.modals.backdropLibrary, + extensionLibraryVisible: state.modals.extensionLibrary }); const mapDispatchToProps = dispatch => ({ onNewSpriteClick: e => { @@ -104,6 +106,9 @@ const mapDispatchToProps = dispatch => ({ onRequestCloseCostumeLibrary: () => { dispatch(closeCostumeLibrary()); }, + onRequestCloseExtensionLibrary: () => { + dispatch(closeExtensionLibrary()); + }, onRequestCloseSoundLibrary: () => { dispatch(closeSoundLibrary()); }, diff --git a/src/examples/extensions/.eslintrc.js b/src/examples/extensions/.eslintrc.js new file mode 100644 index 000000000..15e7ebc28 --- /dev/null +++ b/src/examples/extensions/.eslintrc.js @@ -0,0 +1,9 @@ +module.exports = { + extends: ['scratch'], // no ES6 + env: { + worker: true + }, + globals: { + Scratch: true + } +}; diff --git a/src/examples/extensions/example-extension.js b/src/examples/extensions/example-extension.js new file mode 100644 index 000000000..804faccaa --- /dev/null +++ b/src/examples/extensions/example-extension.js @@ -0,0 +1,230 @@ +var ExampleExtension = function () { +}; + +/** + * @return {object} This extension's metadata. + */ +ExampleExtension.prototype.getInfo = function () { + return { + // Required: the machine-readable name of this extension. + // Will be used as the extension's namespace. Must not contain a '.' character. + id: 'someBlocks', + + // Optional: the human-readable name of this extension as string. + // This and any other string to be displayed in the Scratch UI may either be + // a string or a call to `intlDefineMessage`; a plain string will not be + // translated whereas a call to `intlDefineMessage` will connect the string + // to the translation map (see below). The `intlDefineMessage` call is + // similar to `defineMessages` from `react-intl` in form, but will actually + // call some extension support code to do its magic. For example, we will + // internally namespace the messages such that two extensions could have + // messages with the same ID without colliding. + // See also: https://github.com/yahoo/react-intl/wiki/API#definemessages + name: 'Some Blocks', + + // Optional: URI for an icon for this extension. Data URI OK. + // If not present, use a generic icon. + // TODO: what file types are OK? All web images? Just PNG? + iconURI: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAkAAAAFCAAAAACyOJm3AAAAFklEQVQYV2P4DwMMEMgAI/+DE' + + 'UIMBgAEWB7i7uidhAAAAABJRU5ErkJggg==', + + // Optional: Link to documentation content for this extension. + // If not present, offer no link. + docsURI: 'https://....', + + // Required: the list of blocks implemented by this extension, + // in the order intended for display. + blocks: [ + { + opcode: 'example-noop', + blockType: Scratch.BlockType.COMMAND, + blockAllThreads: false, + text: 'do nothing', + func: 'noop' + }, + { + opcode: 'example-conditional', + blockType: Scratch.BlockType.CONDITIONAL, + branchCount: 4, + isTerminal: true, + blockAllThreads: false, + text: 'choose [BRANCH]', + arguments: { + BRANCH: { + type: Scratch.ArgumentType.NUMBER, + defaultValue: 1 + } + }, + func: 'noop' + }, + { + // Required: the machine-readable name of this operation. + // This will appear in project JSON. Must not contain a '.' character. + opcode: 'myReporter', // becomes 'someBlocks.myReporter' + + // Required: the kind of block we're defining, from a predefined list: + // 'command' - a normal command block, like "move {} steps" + // 'reporter' - returns a value, like "direction" + // 'Boolean' - same as 'reporter' but returns a Boolean value + // 'hat' - starts a stack if its value is truthy + // 'conditional' - control flow, like "if {}" or "repeat {}" + // A 'conditional' block may return the one-based index of a branch + // to run, or it may return zero/falsy to run no branch. Each time a + // child branch finishes, the block is called again. This is only a + // slight change to the current model for control flow blocks, and is + // also compatible with returning true/false for an "if" or "repeat" + // block. + // TODO: Consider Blockly-like nextStatement, previousStatement, and + // output attributes as an alternative. Those are more flexible, but + // allow bad combinations. + blockType: Scratch.BlockType.REPORTER, + + // Required for conditional blocks, ignored for others: the number of + // child branches this block controls. An "if" or "repeat" block would + // specify a branch count of 1; an "if-else" block would specify a + // branch count of 2. + // TODO: should we support dynamic branch count for "switch"-likes? + branchCount: 0, + + // Optional, default false: whether or not this block ends a stack. + // The "forever" and "stop all" blocks would specify true here. + isTerminal: true, + + // Optional, default false: whether or not to block all threads while + // this block is busy. This is for things like the "touching color" + // block in compatibility mode, and is only needed if the VM runs in a + // worker. We might even consider omitting it from extension docs... + blockAllThreads: false, + + // Required: the human-readable text on this block, including argument + // placeholders. Argument placeholders should be in [MACRO_CASE] and + // must be [ENCLOSED_WITHIN_SQUARE_BRACKETS]. + text: 'letter [LETTER_NUM] of [TEXT]', + + // Required: describe each argument. + // Note that this is an array: the order of arguments will be used + arguments: { + // Required: the ID of the argument, which will be the name in the + // args object passed to the implementation function. + LETTER_NUM: { + // Required: type of the argument / shape of the block input + type: Scratch.ArgumentType.NUMBER, + + // Optional: the default value of the argument + defaultValue: 1 + }, + + // Required: the ID of the argument, which will be the name in the + // args object passed to the implementation function. + TEXT: { + // Required: type of the argument / shape of the block input + type: Scratch.ArgumentType.STRING, + + // Optional: the default value of the argument + defaultValue: 'text' + } + }, + + // Optional: a string naming the function implementing this block. + // If this is omitted, use the opcode string. + func: 'myReporter', + + // Optional: list of target types for which this block should appear. + // If absent, assume it applies to all builtin targets -- that is: + // ['sprite', 'stage'] + filter: ['someBlocks.wedo2', 'sprite', 'stage'] + }, + { + opcode: 'example-Boolean', + blockType: Scratch.BlockType.BOOLEAN, + text: 'return true', + func: 'returnTrue' + }, + { + opcode: 'example-hat', + blockType: Scratch.BlockType.HAT, + text: 'after forever', + func: 'returnFalse' + }, + { + // Another block... + } + ], + + // Optional: define extension-specific menus here. + menus: { + // Required: an identifier for this menu, unique within this extension. + menuA: [ + // Static menu: list items which should appear in the menu. + { + // Required: the value of the menu item when it is chosen. + value: 'itemId1', + + // Optional: the human-readable label for this item. + // Use `value` as the text if this is absent. + text: 'Item One' + }, + + // The simplest form of a list item is a string which will be used as + // both value and text. + 'itemId2' + ], + + // Dynamic menu: a string naming a function which returns an array as above. + // Called each time the menu is opened. + menuB: 'getItemsForMenuB' + }, + + // Optional: translations + translation_map: { + de: { + 'extensionName': 'Einige Blöcke', + 'myReporter': 'Buchstabe [LETTER_NUM] von [TEXT]', + 'myReporter.TEXT_default': 'Text', + 'menuA_item1': 'Artikel eins', + + // Dynamic menus can be translated too + 'menuB_example': 'Beispiel', + + // This message contains ICU placeholders (see `myReporter()` below) + 'myReporter.result': 'Buchstabe {LETTER_NUM} von {TEXT} ist {LETTER}.' + }, + it: { + // ... + } + }, + + // Optional: list new target type(s) provided by this extension. + targetTypes: [ + 'wedo2', // automatically transformed to 'someBlocks.wedo2' + 'speech' // automatically transformed to 'someBlocks.speech' + ] + }; +}; + +/** + * Implement myReporter. + * @param {object} args - the block's arguments. + * @property {number} LETTER_NUM - the string value of the argument. + * @property {string} TEXT - the string value of the argument. + * @returns {string} a string which includes the block argument value. + */ +ExampleExtension.prototype.myReporter = function (args) { + // Note: this implementation is not Unicode-clean; it's just here as an example. + const result = args.TEXT.charAt(args.LETTER_NUM); + + return ['Letter ', args.LETTER_NUM, ' of ', args.TEXT, ' is ', result, '.'].join(''); +}; + +ExampleExtension.prototype.noop = function () { +}; + +ExampleExtension.prototype.returnTrue = function () { + return true; +}; + +ExampleExtension.prototype.returnFalse = function () { + return false; +}; + +Scratch.extensions.register(new ExampleExtension()); diff --git a/src/lib/blocks.js b/src/lib/blocks.js index 662eb245f..2e531cc01 100644 --- a/src/lib/blocks.js +++ b/src/lib/blocks.js @@ -29,19 +29,24 @@ export default function (vm) { }; const soundsMenu = function () { - const sounds = vm.editingTarget.sprite.sounds; - if (sounds.length === 0) { - return [['', '']]; + if (vm.editingTarget && vm.editingTarget.sprite.sounds.length > 0) { + return vm.editingTarget.sprite.sounds.map(sound => [sound.name, sound.name]); } - return sounds.map(sound => [sound.name, sound.name]); + return [['', '']]; }; const costumesMenu = function () { - return vm.editingTarget.sprite.costumes.map(costume => [costume.name, costume.name]); + if (vm.editingTarget && vm.editingTarget.sprite.costumes.length > 0) { + return vm.editingTarget.sprite.costumes.map(costume => [costume.name, costume.name]); + } + return [['', '']]; }; const backdropsMenu = function () { - return vm.runtime.targets[0].sprite.costumes.map(costume => [costume.name, costume.name]); + if (vm.runtime.targets[0] && vm.runtime.targets[0].sprite.costumes.length > 0) { + return vm.runtime.targets[0].sprite.costumes.map(costume => [costume.name, costume.name]); + } + return [['', '']]; }; const spriteMenu = function () { @@ -100,6 +105,14 @@ export default function (vm) { this.jsonInit(json); }; + ScratchBlocks.Blocks.motion_glideto_menu.init = function () { + const json = jsonForMenuBlock('TO', spriteMenu, motionColors, [ + ['mouse-pointer', '_mouse_'], + ['random position', '_random_'] + ]); + this.jsonInit(json); + }; + ScratchBlocks.Blocks.sensing_of_object_menu.init = function () { const json = jsonForMenuBlock('OBJECT', spriteMenu, sensingColors, [ ['Stage', '_stage_'] diff --git a/src/lib/libraries/extensions.json b/src/lib/libraries/extensions.json new file mode 100644 index 000000000..d23bb965a --- /dev/null +++ b/src/lib/libraries/extensions.json @@ -0,0 +1,7 @@ +[ + { + "name": "Some Blocks", + "extensionURL": "static/extensions/example-extension.js", + "md5": "76f99773dd4eb59f86cd11bba6ad3cb9.svg" + } +] diff --git a/src/lib/make-toolbox-xml.js b/src/lib/make-toolbox-xml.js new file mode 100644 index 000000000..7561bb2b0 --- /dev/null +++ b/src/lib/make-toolbox-xml.js @@ -0,0 +1,728 @@ +const separator = ''; + +const top = ` + + + + + + + + + 10 + + + + + + + 15 + + + + + + + + + + + + + 10 + + + + + + + 10 + + + + + + + 1 + + + + + + + 1 + + + + + 10 + + + + +`; + +const motion = ` + + + + + 10 + + + + + + + 15 + + + + + + + 15 + + + + + + + 90 + + + + + + + + + + + + + 0 + + + + + 0 + + + + + + + + + + + + + 1 + + + + + 0 + + + + + 0 + + + + + + + 1 + + + + + + + + + + + 10 + + + + + + + 0 + + + + + + + 10 + + + + + + + 0 + + + + + + + + + +`; + +const looks = ` + + + + + + + + + + + + + + + + + + + + + + + + 10 + + + + + + + 10 + + + + + + + + 10 + + + + + + + 100 + + + + + + + + 1 + + + + + + + + +`; + +const sound = ` + + + + + + + + + + + + + + + + + + + 0.25 + + + + + + + 0.25 + + + + + + + 60 + + + + + 0.5 + + + + + + + + + + + + 10 + + + + + + + 100 + + + + + + + + -10 + + + + + + + 100 + + + + + + + + 20 + + + + + + + 60 + + + + + +`; + +const pen = ` + + + + + + + + + + + + + + + 10 + + + + + + + 0 + + + + + + + 10 + + + + + + + 50 + + + + + + + 1 + + + + + + + 1 + + + + + + + 10 + + + + + + + 50 + + + + +`; + +const events = ` + + + + + + + + + + + 10 + + + + + + + + + + + + + + + + +`; + +const control = ` + + + + + 1 + + + + + + + 10 + + + + + + + + + + + + + + + + + +`; + +const sensing = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +const operators = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1 + + + + + 10 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + hello + + + + + world + + + + + + + 1 + + + + + world + + + + + + + world + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +const data = ` + + +`; + +const xmlOpen = ''; +const xmlClose = ''; + +/** + * @param {string?} categoriesXML - null for default toolbox, or an XML string with elements. + * @returns {string} - a ScratchBlocks-style XML document for the contents of the toolbox. + */ +const makeToolboxXML = function (categoriesXML) { + const gap = [separator]; + + const everything = [ + xmlOpen, + top, gap, + motion, gap, + looks, gap, + sound, gap, + events, gap, + control, gap, + sensing, gap, + pen, gap, + operators, gap, + data + ]; + + if (categoriesXML) { + everything.push(gap, categoriesXML); + } + + everything.push(xmlClose); + + return everything.join('\n'); +}; + +export default makeToolboxXML; diff --git a/src/reducers/color-picker.js b/src/reducers/color-picker.js new file mode 100644 index 000000000..fc16354a0 --- /dev/null +++ b/src/reducers/color-picker.js @@ -0,0 +1,40 @@ +const ACTIVATE_COLOR_PICKER = 'scratch-gui/color-picker/ACTIVATE_COLOR_PICKER'; +const DEACTIVATE_COLOR_PICKER = 'scratch-gui/color-picker/DEACTIVATE_COLOR_PICKER'; +const SET_CALLBACK = 'scratch-gui/color-picker/SET_CALLBACK'; + +const initialState = { + active: false, + callback: () => { + throw new Error('Color picker callback not initialized'); + } +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case ACTIVATE_COLOR_PICKER: + return Object.assign({}, state, {active: true, callback: action.callback}); + case DEACTIVATE_COLOR_PICKER: + // Can be called without a string to deactivate without setting color + // i.e. when clicking on the modal background + if (typeof action.color === 'string') { + state.callback(action.color); + } + return Object.assign({}, state, {active: false}); + case SET_CALLBACK: + return Object.assign({}, state, {callback: action.callback}); + default: + return state; + } +}; + +const activateColorPicker = callback => ({type: ACTIVATE_COLOR_PICKER, callback: callback}); +const deactivateColorPicker = color => ({type: DEACTIVATE_COLOR_PICKER, color: color}); +const setCallback = callback => ({type: SET_CALLBACK, callback: callback}); + +export { + reducer as default, + activateColorPicker, + deactivateColorPicker, + setCallback +}; diff --git a/src/reducers/gui.js b/src/reducers/gui.js index c83dd7fca..fef037d92 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -1,15 +1,18 @@ import {combineReducers} from 'redux'; +import colorPickerReducer from './color-picker'; import intlReducer from './intl'; import modalReducer from './modals'; import monitorReducer from './monitors'; import targetReducer from './targets'; +import toolboxReducer from './toolbox'; import vmReducer from './vm'; - export default combineReducers({ + colorPicker: colorPickerReducer, intl: intlReducer, modals: modalReducer, monitors: monitorReducer, targets: targetReducer, + toolbox: toolboxReducer, vm: vmReducer }); diff --git a/src/reducers/modals.js b/src/reducers/modals.js index e651f0517..55a55e4fe 100644 --- a/src/reducers/modals.js +++ b/src/reducers/modals.js @@ -6,10 +6,12 @@ const MODAL_COSTUME_LIBRARY = 'costumeLibrary'; const MODAL_SOUND_LIBRARY = 'soundLibrary'; const MODAL_SPRITE_LIBRARY = 'spriteLibrary'; const MODAL_SOUND_RECORDER = 'soundRecorder'; +const MODAL_EXTENSION_LIBRARY = 'extensionLibrary'; const initialState = { [MODAL_BACKDROP_LIBRARY]: false, [MODAL_COSTUME_LIBRARY]: false, + [MODAL_EXTENSION_LIBRARY]: false, [MODAL_SOUND_LIBRARY]: false, [MODAL_SPRITE_LIBRARY]: false, [MODAL_SOUND_RECORDER]: false @@ -57,12 +59,18 @@ const openSpriteLibrary = function () { const openSoundRecorder = function () { return openModal(MODAL_SOUND_RECORDER); }; +const openExtensionLibrary = function () { + return openModal(MODAL_EXTENSION_LIBRARY); +}; const closeBackdropLibrary = function () { return closeModal(MODAL_BACKDROP_LIBRARY); }; const closeCostumeLibrary = function () { return closeModal(MODAL_COSTUME_LIBRARY); }; +const closeExtensionLibrary = function () { + return closeModal(MODAL_EXTENSION_LIBRARY); +}; const closeSpriteLibrary = function () { return closeModal(MODAL_SPRITE_LIBRARY); }; @@ -74,6 +82,7 @@ const closeSoundRecorder = function () { }; export { reducer as default, + openExtensionLibrary, openBackdropLibrary, openCostumeLibrary, openSoundLibrary, @@ -81,6 +90,7 @@ export { openSoundRecorder, closeBackdropLibrary, closeCostumeLibrary, + closeExtensionLibrary, closeSpriteLibrary, closeSoundLibrary, closeSoundRecorder diff --git a/src/reducers/toolbox.js b/src/reducers/toolbox.js new file mode 100644 index 000000000..4b06a807c --- /dev/null +++ b/src/reducers/toolbox.js @@ -0,0 +1,31 @@ +const UPDATE_TOOLBOX = 'scratch-gui/toolbox/UPDATE_TOOLBOX'; + +import makeToolboxXML from '../lib/make-toolbox-xml'; + +const initialState = { + toolboxXML: makeToolboxXML() +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case UPDATE_TOOLBOX: + return Object.assign({}, state, { + toolboxXML: action.toolboxXML + }); + default: + return state; + } +}; + +const updateToolbox = function (toolboxXML) { + return { + type: UPDATE_TOOLBOX, + toolboxXML: toolboxXML + }; +}; + +export { + reducer as default, + updateToolbox +}; diff --git a/test/helpers/selenium-helper.js b/test/helpers/selenium-helper.js index 390740c3c..b250bcbe5 100644 --- a/test/helpers/selenium-helper.js +++ b/test/helpers/selenium-helper.js @@ -33,7 +33,7 @@ class SeleniumHelper { } clickText (text) { - return this.clickXpath(`//*[contains(text(), '${text}')]`); + return this.clickXpath(`//body//*[contains(text(), '${text}')]`); } clickButton (text) { diff --git a/webpack.config.js b/webpack.config.js index f39fc81a9..6c486cc8a 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -99,6 +99,15 @@ module.exports = { new CopyWebpackPlugin([{ from: 'node_modules/scratch-blocks/media', to: 'static/blocks-media' + }]), + new CopyWebpackPlugin([{ + from: 'extensions/**', + to: 'static', + context: 'src/examples' + }]), + new CopyWebpackPlugin([{ + from: 'extension-worker.{js,js.map}', + context: 'node_modules/scratch-vm/dist/web' }]) ].concat(process.env.NODE_ENV === 'production' ? [ new webpack.optimize.UglifyJsPlugin({