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