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({