diff --git a/src/components/gui/gui.css b/src/components/gui/gui.css index 6412c9a88..2c4715479 100644 --- a/src/components/gui/gui.css +++ b/src/components/gui/gui.css @@ -148,3 +148,18 @@ height: $stage-menu-height; padding: $space; } + +.extension-button { + font-size: 0.55rem; + font-weight: bold; + position: absolute; + bottom: 0.75rem; + left: 1rem; + border: 1px solid lightgray; + background: white; + z-index: 50; /** Force extension button above the ScratchBlocks flyout. */ +} + +.hidden { + display: none; +} diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 9b45665a6..d230476b8 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 '../sprite-selector/icon--sprite.svg'; + +const addExtensionMessage = ( + +); const GUIComponent = props => { const { basePath, children, + enableExtensions, vm, + onAddExtensionClick, onTabSelect, tabIndex, ...componentProps @@ -77,6 +90,14 @@ const GUIComponent = props => { vm={vm} /> + {tabIndex === 1 ? : null} @@ -115,6 +136,8 @@ const GUIComponent = props => { GUIComponent.propTypes = { basePath: PropTypes.string, children: PropTypes.node, + enableExtensions: PropTypes.bool, + onAddExtensionClick: PropTypes.func, onTabSelect: PropTypes.func, tabIndex: PropTypes.number, vm: PropTypes.instanceOf(VM).isRequired diff --git a/src/containers/blocks.jsx b/src/containers/blocks.jsx index 63a762765..fbd4ecb6d 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,9 @@ 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'; + const addFunctionListener = (object, property, callback) => { const oldFn = object[property]; object[property] = function () { @@ -31,6 +35,7 @@ class Blocks extends React.Component { 'onScriptGlowOff', 'onBlockGlowOn', 'onBlockGlowOff', + 'handleExtensionAdded', 'onTargetsUpdate', 'onVisualReport', 'onWorkspaceUpdate', @@ -55,9 +60,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 +93,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 +121,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 +131,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 +199,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; } @@ -185,6 +223,8 @@ class Blocks extends React.Component { options, // eslint-disable-line no-unused-vars vm, // eslint-disable-line no-unused-vars isVisible, // eslint-disable-line no-unused-vars + onExtensionAdded, // eslint-disable-line no-unused-vars + toolboxXML, // eslint-disable-line no-unused-vars ...props } = this.props; return ( @@ -209,6 +249,7 @@ class Blocks extends React.Component { Blocks.propTypes = { isVisible: PropTypes.bool, + onExtensionAdded: PropTypes.func, options: PropTypes.shape({ media: PropTypes.string, zoom: PropTypes.shape({ @@ -230,6 +271,7 @@ Blocks.propTypes = { }), comments: PropTypes.bool }), + toolboxXML: PropTypes.string, vm: PropTypes.instanceOf(VM).isRequired }; @@ -264,4 +306,17 @@ Blocks.defaultProps = { options: Blocks.defaultOptions }; -export default Blocks; +const mapStateToProps = state => ({ + toolboxXML: state.toolbox.toolboxXML +}); + +const mapDispatchToProps = dispatch => ({ + onExtensionAdded: toolboxXML => { + dispatch(updateToolbox(toolboxXML)); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Blocks); diff --git a/src/containers/gui.jsx b/src/containers/gui.jsx index c41f7a9a9..0fe10b4d1 100644 --- a/src/containers/gui.jsx +++ b/src/containers/gui.jsx @@ -12,6 +12,7 @@ class GUI extends React.Component { constructor (props) { super(props); bindAll(this, [ + 'handleAddExtensionClick', 'handleTabSelect' ]); this.state = {tabIndex: 0}; @@ -34,6 +35,10 @@ class GUI extends React.Component { handleTabSelect (tabIndex) { this.setState({tabIndex}); } + handleAddExtensionClick () { + /** @TODO open modal for the extension library instead */ + this.props.vm.extensionManager.loadExtensionURL('static/extensions/example-extension.js'); + } render () { const { children, @@ -43,8 +48,10 @@ class GUI extends React.Component { } = this.props; return ( 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/make-toolbox-xml.js b/src/lib/make-toolbox-xml.js new file mode 100644 index 000000000..cbdbf771e --- /dev/null +++ b/src/lib/make-toolbox-xml.js @@ -0,0 +1,640 @@ +const separator = ''; + +const motion = ` + + + + + 10 + + + + + + + 15 + + + + + + + 15 + + + + + + + 90 + + + + + + + + + + + + + 0 + + + + + 0 + + + + + + + + + + + + + 1 + + + + + 0 + + + + + 0 + + + + + + + 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 + + + + +`; + +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, + motion, gap, + looks, gap, + sound, gap, + pen, gap, + data, gap, + events, gap, + control, gap, + sensing, gap, + operators + ]; + + if (categoriesXML) { + everything.push(gap, categoriesXML); + } + + everything.push(xmlClose); + + return everything.join('\n'); +}; + +export default makeToolboxXML; diff --git a/src/reducers/gui.js b/src/reducers/gui.js index c83dd7fca..dfe05dd84 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -3,6 +3,7 @@ import intlReducer from './intl'; import modalReducer from './modals'; import monitorReducer from './monitors'; import targetReducer from './targets'; +import toolboxReducer from './toolbox'; import vmReducer from './vm'; @@ -11,5 +12,6 @@ export default combineReducers({ modals: modalReducer, monitors: monitorReducer, targets: targetReducer, + toolbox: toolboxReducer, vm: vmReducer }); 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({