diff --git a/package.json b/package.json
index 919cd38652b..e892dc02d54 100644
--- a/package.json
+++ b/package.json
@@ -45,9 +45,11 @@
"opt-cli": "1.5.1",
"react": "15.x.x",
"react-dom": "15.x.x",
+ "react-modal": "1.5.2",
"scratch-blocks": "latest",
"scratch-render": "latest",
"scratch-vm": "latest",
+ "svg-to-image": "1.1.3",
"travis-after-all": "jamesarosen/travis-after-all#override-api-urls",
"webpack": "1.13.2",
"webpack-dev-server": "1.15.2",
diff --git a/src/components/costume-canvas.js b/src/components/costume-canvas.js
new file mode 100644
index 00000000000..368d52a9be7
--- /dev/null
+++ b/src/components/costume-canvas.js
@@ -0,0 +1,117 @@
+const React = require('react');
+const svgToImage = require('svg-to-image');
+const xhr = require('xhr');
+
+/**
+ * @fileoverview
+ * A component for rendering Scratch costume URLs to canvases.
+ * Use for sprite library, costume library, sprite selector, etc.
+ * Props include width, height, and direction (direction in Scratch value).
+ */
+
+class CostumeCanvas extends React.Component {
+ componentDidMount () {
+ this.load();
+ }
+ componentDidUpdate (prevProps) {
+ if (prevProps.url !== this.props.url) {
+ this.load();
+ } else {
+ if (prevProps.width !== this.props.width ||
+ prevProps.height !== this.props.height ||
+ prevProps.direction !== this.props.direction) {
+ this.draw();
+ }
+ }
+ }
+ draw () {
+ if (!this.refs.costumeCanvas) {
+ return;
+ }
+ // Draw the costume to the rendered canvas.
+ const img = this.img;
+ const context = this.refs.costumeCanvas.getContext('2d');
+ // Scale to fit.
+ let scale;
+ // Choose the larger dimension to scale by.
+ if (img.width > img.height) {
+ scale = this.refs.costumeCanvas.width / img.width;
+ } else {
+ scale = this.refs.costumeCanvas.height / img.height;
+ }
+ // Rotate by the Scratch-value direction.
+ const angle = (-90 + this.props.direction) * Math.PI / 180;
+ // Rotation origin point will be center of the canvas.
+ const contextTranslateX = this.refs.costumeCanvas.width / 2;
+ const contextTranslateY = this.refs.costumeCanvas.height / 2;
+ // First, clear the canvas.
+ context.clearRect(0, 0,
+ this.refs.costumeCanvas.width, this.refs.costumeCanvas.height);
+ // Translate the context to the center of the canvas,
+ // then rotate canvas drawing by `angle`.
+ context.translate(contextTranslateX, contextTranslateY);
+ context.rotate(angle);
+ context.drawImage(img,
+ 0, 0, img.width, img.height,
+ -(scale * img.width / 2), -(scale * img.height / 2),
+ scale * img.width,
+ scale * img.height);
+ // Reset the canvas rotation and translation to 0, (0, 0).
+ context.rotate(-angle);
+ context.translate(-contextTranslateX, -contextTranslateY);
+ }
+ load () {
+ // Draw the icon on our canvas.
+ const url = this.props.url;
+ if (url.indexOf('.svg') > -1) {
+ // Vector graphics: need to download with XDR and rasterize.
+ // Queue request asynchronously.
+ setTimeout(() => {
+ xhr.get({
+ useXDR: true,
+ url: url
+ }, (err, response, body) => {
+ if (!err) {
+ svgToImage(body, (err, img) => {
+ if (!err) {
+ this.img = img;
+ this.draw();
+ }
+ });
+ }
+ });
+ }, 0);
+
+ } else {
+ // Raster graphics: create Image and draw it.
+ let img = new Image();
+ img.src = url;
+ img.onload = () => {
+ this.img = img;
+ this.draw();
+ };
+ }
+ }
+ render () {
+ return ;
+ }
+}
+
+CostumeCanvas.defaultProps = {
+ width: 100,
+ height: 100,
+ direction: 90
+};
+
+CostumeCanvas.propTypes = {
+ url: React.PropTypes.string,
+ width: React.PropTypes.number,
+ height: React.PropTypes.number,
+ direction: React.PropTypes.number
+};
+
+module.exports = CostumeCanvas;
diff --git a/src/components/library-item.js b/src/components/library-item.js
new file mode 100644
index 00000000000..39f65596a34
--- /dev/null
+++ b/src/components/library-item.js
@@ -0,0 +1,49 @@
+const React = require('react');
+
+const CostumeCanvas = require('./costume-canvas');
+
+class LibraryItem extends React.Component {
+ render () {
+ let style = (this.props.selected) ?
+ this.props.selectedGridTileStyle : this.props.gridTileStyle;
+ return (
+
this.props.onSelect(this.props.id)}>
+
+
{this.props.name}
+
+ );
+ }
+}
+
+LibraryItem.defaultProps = {
+ gridTileStyle: {
+ float: 'left',
+ width: '140px',
+ marginLeft: '5px',
+ marginRight: '5px',
+ textAlign: 'center',
+ cursor: 'pointer'
+ },
+ selectedGridTileStyle: {
+ float: 'left',
+ width: '140px',
+ marginLeft: '5px',
+ marginRight: '5px',
+ textAlign: 'center',
+ cursor: 'pointer',
+ background: '#aaa',
+ borderRadius: '6px'
+ }
+};
+
+LibraryItem.propTypes = {
+ name: React.PropTypes.string,
+ iconURL: React.PropTypes.string,
+ gridTileStyle: React.PropTypes.object,
+ selectedGridTileStyle: React.PropTypes.object,
+ selected: React.PropTypes.bool,
+ onSelect: React.PropTypes.func,
+ id: React.PropTypes.number
+};
+
+module.exports = LibraryItem;
diff --git a/src/components/library.js b/src/components/library.js
new file mode 100644
index 00000000000..f23f4b8267d
--- /dev/null
+++ b/src/components/library.js
@@ -0,0 +1,69 @@
+const bindAll = require('lodash.bindall');
+const React = require('react');
+
+const LibraryItem = require('./library-item');
+const ModalComponent = require('./modal');
+
+class LibraryComponent extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, ['onSelect']);
+ this.state = {selectedItem: null};
+ }
+ onSelect (id) {
+ if (this.state.selectedItem == id) {
+ // Double select: select as the library's value.
+ this.props.onRequestClose();
+ this.props.onItemSelected(this.props.data[id]);
+ }
+ this.setState({selectedItem: id});
+ }
+ render () {
+ let itemId = 0;
+ let gridItems = this.props.data.map((dataItem) => {
+ let id = itemId;
+ itemId++;
+ const scratchURL = (dataItem.md5) ? 'https://cdn.assets.scratch.mit.edu/internalapi/asset/' +
+ dataItem.md5 + '/get/' : dataItem.rawURL;
+ return ;
+ });
+
+ const scrollGridStyle = {
+ overflow: 'scroll',
+ position: 'absolute',
+ top: '70px',
+ bottom: '20px',
+ left: '30px',
+ right: '30px'
+ };
+
+ return (
+
+ {this.props.title}
+
+ {gridItems}
+
+
+ );
+ }
+}
+
+LibraryComponent.propTypes = {
+ title: React.PropTypes.string,
+ data: React.PropTypes.array,
+ visible: React.PropTypes.bool,
+ onRequestClose: React.PropTypes.func,
+ onItemSelected: React.PropTypes.func
+};
+
+module.exports = LibraryComponent;
diff --git a/src/components/modal.js b/src/components/modal.js
new file mode 100644
index 00000000000..a2c25714964
--- /dev/null
+++ b/src/components/modal.js
@@ -0,0 +1,70 @@
+const React = require('react');
+const ReactModal = require('react-modal');
+
+class ModalComponent extends React.Component {
+ render () {
+ return (
+
+
+ x
+
+ {this.props.children}
+
+ );
+ }
+}
+
+const modalStyle = {
+ overlay: {
+ zIndex: 1000,
+ backgroundColor: 'rgba(0, 0, 0, .75)'
+ },
+ content: {
+ position: 'absolute',
+ overflow: 'visible',
+ borderRadius: '6px',
+ padding: 0,
+ top: '5%',
+ bottom: '5%',
+ left: '5%',
+ right: '5%',
+ background: '#fcfcfc'
+ }
+};
+
+const closeButtonStyle = {
+ color: 'rgb(255, 255, 255)',
+ background: 'rgb(50, 50, 50)',
+ borderRadius: '15px',
+ width: '30px',
+ height: '25px',
+ textAlign: 'center',
+ paddingTop: '5px',
+ position: 'absolute',
+ right: '3px',
+ top: '3px',
+ cursor: 'pointer'
+};
+
+ModalComponent.defaultProps = {
+ modalStyle: modalStyle,
+ closeButtonStyle: closeButtonStyle
+};
+
+ModalComponent.propTypes = {
+ children: React.PropTypes.node,
+ modalStyle: React.PropTypes.object,
+ closeButtonStyle: React.PropTypes.object,
+ onRequestClose: React.PropTypes.func,
+ visible: React.PropTypes.bool
+};
+
+module.exports = ModalComponent;
diff --git a/src/components/sprite-selector.js b/src/components/sprite-selector.js
index a37213d9b25..da00622bf8f 100644
--- a/src/components/sprite-selector.js
+++ b/src/components/sprite-selector.js
@@ -6,6 +6,9 @@ class SpriteSelectorComponent extends React.Component {
onChange,
sprites,
value,
+ openNewSprite,
+ openNewCostume,
+ openNewBackdrop,
...props
} = this.props;
return (
@@ -28,6 +31,11 @@ class SpriteSelectorComponent extends React.Component {
))}
+
+
+
+
+
);
}
@@ -41,7 +49,10 @@ SpriteSelectorComponent.propTypes = {
name: React.PropTypes.string
})
),
- value: React.PropTypes.arrayOf(React.PropTypes.string)
+ value: React.PropTypes.arrayOf(React.PropTypes.string),
+ openNewSprite: React.PropTypes.func,
+ openNewCostume: React.PropTypes.func,
+ openNewBackdrop: React.PropTypes.func
};
module.exports = SpriteSelectorComponent;
diff --git a/src/containers/backdrop-library.js b/src/containers/backdrop-library.js
new file mode 100644
index 00000000000..bcac13b5237
--- /dev/null
+++ b/src/containers/backdrop-library.js
@@ -0,0 +1,53 @@
+const bindAll = require('lodash.bindall');
+const React = require('react');
+const VM = require('scratch-vm');
+const MediaLibrary = require('../lib/media-library');
+
+const LibaryComponent = require('../components/library');
+
+
+class BackdropLibrary extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, ['setData', 'selectItem']);
+ this.state = {backdropData: []};
+ }
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.visible && this.state.backdropData.length === 0) {
+ this.props.mediaLibrary.getMediaLibrary('backdrop', this.setData);
+ }
+ }
+ setData (data) {
+ this.setState({backdropData: data});
+ }
+ selectItem (item) {
+ var vmBackdrop = {
+ skin: 'https://cdn.assets.scratch.mit.edu/internalapi/asset/' + item.md5 + '/get/',
+ name: item.name,
+ rotationCenterX: item.info[0],
+ rotationCenterY: item.info[1]
+ };
+ if (item.info.length > 2) {
+ vmBackdrop.bitmapResolution = item.info[2];
+ }
+ this.props.vm.addBackdrop(vmBackdrop);
+ }
+ render () {
+ return ;
+ }
+}
+
+BackdropLibrary.propTypes = {
+ vm: React.PropTypes.instanceOf(VM).isRequired,
+ mediaLibrary: React.PropTypes.instanceOf(MediaLibrary),
+ visible: React.PropTypes.bool,
+ onRequestClose: React.PropTypes.func
+};
+
+module.exports = BackdropLibrary;
diff --git a/src/containers/costume-library.js b/src/containers/costume-library.js
new file mode 100644
index 00000000000..091a736ffbb
--- /dev/null
+++ b/src/containers/costume-library.js
@@ -0,0 +1,53 @@
+const bindAll = require('lodash.bindall');
+const React = require('react');
+const VM = require('scratch-vm');
+const MediaLibrary = require('../lib/media-library');
+
+const LibaryComponent = require('../components/library');
+
+
+class CostumeLibrary extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, ['setData', 'selectItem']);
+ this.state = {costumeData: []};
+ }
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.visible && this.state.costumeData.length === 0) {
+ this.props.mediaLibrary.getMediaLibrary('costume', this.setData);
+ }
+ }
+ setData (data) {
+ this.setState({costumeData: data});
+ }
+ selectItem (item) {
+ var vmCostume = {
+ skin: 'https://cdn.assets.scratch.mit.edu/internalapi/asset/' + item.md5 + '/get/',
+ name: item.name,
+ rotationCenterX: item.info[0],
+ rotationCenterY: item.info[1]
+ };
+ if (item.info.length > 2) {
+ vmCostume.bitmapResolution = item.info[2];
+ }
+ this.props.vm.addCostume(vmCostume);
+ }
+ render () {
+ return ;
+ }
+}
+
+CostumeLibrary.propTypes = {
+ vm: React.PropTypes.instanceOf(VM).isRequired,
+ mediaLibrary: React.PropTypes.instanceOf(MediaLibrary),
+ visible: React.PropTypes.bool,
+ onRequestClose: React.PropTypes.func
+};
+
+module.exports = CostumeLibrary;
diff --git a/src/containers/gui.js b/src/containers/gui.js
index 937f4446f98..0ab742034fa 100644
--- a/src/containers/gui.js
+++ b/src/containers/gui.js
@@ -1,8 +1,10 @@
+const bindAll = require('lodash.bindall');
const defaultsDeep = require('lodash.defaultsdeep');
const React = require('react');
const VM = require('scratch-vm');
const VMManager = require('../lib/vm-manager');
+const MediaLibrary = require('../lib/media-library');
const Blocks = require('./blocks');
const GUIComponent = require('../components/gui');
@@ -11,10 +13,17 @@ const SpriteSelector = require('./sprite-selector');
const Stage = require('./stage');
const StopAll = require('./stop-all');
+const SpriteLibrary = require('./sprite-library');
+const CostumeLibrary = require('./costume-library');
+const BackdropLibrary = require('./backdrop-library');
+
class GUI extends React.Component {
constructor (props) {
super(props);
+ bindAll(this, ['closeModal']);
this.vmManager = new VMManager(this.props.vm);
+ this.mediaLibrary = new MediaLibrary();
+ this.state = {currentModal: null};
}
componentDidMount () {
this.vmManager.attachKeyboardEvents();
@@ -30,12 +39,21 @@ class GUI extends React.Component {
this.props.vm.loadProject(nextProps.projectData);
}
}
+ openModal (modalName) {
+ this.setState({currentModal: modalName});
+ }
+ closeModal () {
+ this.setState({currentModal: null});
+ }
render () {
let {
+ backdropLibraryProps,
basePath,
blocksProps,
+ costumeLibraryProps,
greenFlagProps,
projectData, // eslint-disable-line no-unused-vars
+ spriteLibraryProps,
spriteSelectorProps,
stageProps,
stopAllProps,
@@ -47,6 +65,26 @@ class GUI extends React.Component {
media: basePath + 'static/blocks-media/'
}
});
+ spriteSelectorProps = defaultsDeep({}, spriteSelectorProps, {
+ openNewBackdrop: () => this.openModal('backdrop-library'),
+ openNewCostume: () => this.openModal('costume-library'),
+ openNewSprite: () => this.openModal('sprite-library')
+ });
+ spriteLibraryProps = defaultsDeep({}, spriteLibraryProps, {
+ mediaLibrary: this.mediaLibrary,
+ onRequestClose: this.closeModal,
+ visible: this.state.currentModal == 'sprite-library'
+ });
+ costumeLibraryProps = defaultsDeep({}, costumeLibraryProps, {
+ mediaLibrary: this.mediaLibrary,
+ onRequestClose: this.closeModal,
+ visible: this.state.currentModal == 'costume-library'
+ });
+ backdropLibraryProps = defaultsDeep({}, backdropLibraryProps, {
+ mediaLibrary: this.mediaLibrary,
+ onRequestClose: this.closeModal,
+ visible: this.state.currentModal == 'backdrop-library'
+ });
if (this.props.children) {
return (
@@ -61,6 +99,9 @@ class GUI extends React.Component {
+
+
+
);
}
@@ -68,11 +109,14 @@ class GUI extends React.Component {
}
GUI.propTypes = {
+ backdropLibraryProps: React.PropTypes.object,
basePath: React.PropTypes.string,
blocksProps: React.PropTypes.object,
+ costumeLibraryProps: React.PropTypes.object,
children: React.PropTypes.node,
greenFlagProps: React.PropTypes.object,
projectData: React.PropTypes.string,
+ spriteLibraryProps: React.PropTypes.object,
spriteSelectorProps: React.PropTypes.object,
stageProps: React.PropTypes.object,
stopAllProps: React.PropTypes.object,
@@ -80,10 +124,13 @@ GUI.propTypes = {
};
GUI.defaultProps = {
+ backdropLibraryProps: {},
basePath: '/',
blocksProps: {},
+ costumeLibraryProps: {},
greenFlagProps: {},
spriteSelectorProps: {},
+ spriteLibraryProps: {},
stageProps: {},
stopAllProps: {},
vm: new VM()
diff --git a/src/containers/sprite-library.js b/src/containers/sprite-library.js
new file mode 100644
index 00000000000..602fb177609
--- /dev/null
+++ b/src/containers/sprite-library.js
@@ -0,0 +1,61 @@
+const bindAll = require('lodash.bindall');
+const React = require('react');
+const VM = require('scratch-vm');
+const MediaLibrary = require('../lib/media-library');
+
+const LibaryComponent = require('../components/library');
+
+class SpriteLibrary extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, ['setData', 'selectItem', 'setSpriteData']);
+ this.state = {data: [], spriteData: {}};
+ }
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.visible && this.state.data.length === 0) {
+ this.props.mediaLibrary.getMediaLibrary('sprite', this.setData);
+ }
+ }
+ setData (data) {
+ this.setState({data: data});
+ for (let sprite of data) {
+ this.props.mediaLibrary.getSprite(sprite.md5, this.setSpriteData);
+ }
+ }
+ setSpriteData (md5, data) {
+ let spriteData = this.state.spriteData;
+ spriteData[md5] = data;
+ this.setState({spriteData: spriteData});
+ }
+ selectItem (item) {
+ var spriteData = JSON.stringify(this.state.spriteData[item.json]);
+ this.props.vm.addSprite2(spriteData);
+ }
+ render () {
+ let libraryData = Object.keys(this.state.spriteData).map((libraryKey) => {
+ let libraryItem = this.state.spriteData[libraryKey];
+ return {
+ name: libraryItem.objName,
+ md5: libraryItem.costumes[0].baseLayerMD5,
+ json: libraryKey
+ };
+ });
+ return ;
+ }
+}
+
+SpriteLibrary.propTypes = {
+ vm: React.PropTypes.instanceOf(VM).isRequired,
+ mediaLibrary: React.PropTypes.instanceOf(MediaLibrary),
+ visible: React.PropTypes.bool,
+ onRequestClose: React.PropTypes.func
+};
+
+module.exports = SpriteLibrary;
diff --git a/src/containers/sprite-selector.js b/src/containers/sprite-selector.js
index 2f5c83d44f7..c30ac713784 100644
--- a/src/containers/sprite-selector.js
+++ b/src/containers/sprite-selector.js
@@ -25,12 +25,18 @@ class SpriteSelector extends React.Component {
render () {
const {
vm, // eslint-disable-line no-unused-vars
+ openNewSprite,
+ openNewCostume,
+ openNewBackdrop,
...props
} = this.props;
return (
(
{
id: target[0],
@@ -44,7 +50,10 @@ class SpriteSelector extends React.Component {
}
SpriteSelector.propTypes = {
- vm: React.PropTypes.object.isRequired
+ vm: React.PropTypes.object.isRequired,
+ openNewSprite: React.PropTypes.func,
+ openNewCostume: React.PropTypes.func,
+ openNewBackdrop: React.PropTypes.func
};
module.exports = SpriteSelector;
diff --git a/src/lib/media-library.js b/src/lib/media-library.js
new file mode 100644
index 00000000000..6f33b25c919
--- /dev/null
+++ b/src/lib/media-library.js
@@ -0,0 +1,80 @@
+const xhr = require('xhr');
+
+const LIBRARY_PREFIX = 'https://cdn.scratch.mit.edu/scratchr2/static/' +
+ '__8d9c95eb5aa1272a311775ca32568417__/medialibraries/';
+const LIBRARY_URL = {
+ sprite: LIBRARY_PREFIX + 'spriteLibrary.json',
+ costume: LIBRARY_PREFIX + 'costumeLibrary.json',
+ backdrop: LIBRARY_PREFIX + 'backdropLibrary.json'
+};
+const SPRITE_OBJECT_PREFIX = 'https://cdn.assets.scratch.mit.edu/internalapi/asset/';
+const SPRITE_OBJECT_SUFFIX = '/get/';
+
+class MediaLibrary {
+ constructor () {
+ /*
+ * Cached library data, from JSON.
+ * @type {Object}
+ */
+ this._libraryData = {};
+
+ /**
+ * Cached sprite data, from JSON.
+ * @type {Object.}
+ */
+ this._spriteData = {};
+ }
+
+ /**
+ * Get the media library data for a particular scratchr2 library.
+ * In the future, load this from `scratch-storage` asset manager,
+ * e.g., for offline support.
+ * @param {string} libraryType Type of library, i.e., sprite, costume, sound, backdrop.
+ * @param {!Function} callback Callback, called with list of data.
+ */
+ getMediaLibrary (libraryType, callback) {
+ if (!this._libraryData.hasOwnProperty(libraryType)) {
+ this._libraryData[libraryType] = null;
+ }
+ if (this._libraryData[libraryType]) {
+ callback(this._libraryData[libraryType]);
+ } else {
+ xhr.get({
+ useXDR: true,
+ url: LIBRARY_URL[libraryType]
+ }, (err, response, body) => {
+ if (!err) {
+ let data = JSON.parse(body);
+ this._libraryData[libraryType] = data;
+ callback(this._libraryData[libraryType]);
+ }
+ });
+ }
+ }
+
+ /**
+ * Get media library info for a specific scratchr2 sprite.
+ * In the future, load this from `scratch-storage` asset manager,
+ * e.g., for offline support.
+ * @param {string} url URL to sprite (md5.json).
+ * @param {!Function} callback Callback, called with sprite data.
+ */
+ getSprite (url, callback) {
+ if (this._spriteData.hasOwnProperty(url)) {
+ callback(url, this._spriteData[url]);
+ } else {
+ xhr.get({
+ useXDR: true,
+ url: SPRITE_OBJECT_PREFIX + url + SPRITE_OBJECT_SUFFIX
+ }, (err, response, body) => {
+ if (!err) {
+ let data = JSON.parse(body);
+ this._spriteData[url] = data;
+ callback(url, data);
+ }
+ });
+ }
+ }
+}
+
+module.exports = MediaLibrary;