diff --git a/package.json b/package.json index 919cd38652bc8a3b306d8015088c921117d617a1..e892dc02d54e550a3a37db108b3ee764a94282c9 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 0000000000000000000000000000000000000000..368d52a9be70056ed9b4f6ad6df427e80289b4eb --- /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 <canvas + ref='costumeCanvas' + width={this.props.width} + height={this.props.height} + />; + } +} + +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 0000000000000000000000000000000000000000..39f65596a34524e88f8abdaf9758b65b90f70b14 --- /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 ( + <div style={style} onClick={() => this.props.onSelect(this.props.id)}> + <CostumeCanvas url={this.props.iconURL} /> + <p>{this.props.name}</p> + </div> + ); + } +} + +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 0000000000000000000000000000000000000000..f23f4b8267d18269ddc9627ece6ae25ff0ff3440 --- /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 <LibraryItem + name={dataItem.name} + iconURL={scratchURL} + key={'item_' + id} + selected={this.state.selectedItem == id} + onSelect={this.onSelect} + id={id} + />; + }); + + const scrollGridStyle = { + overflow: 'scroll', + position: 'absolute', + top: '70px', + bottom: '20px', + left: '30px', + right: '30px' + }; + + return ( + <ModalComponent + onRequestClose={this.props.onRequestClose} + visible={this.props.visible} + > + <h1>{this.props.title}</h1> + <div style={scrollGridStyle}> + {gridItems} + </div> + </ModalComponent> + ); + } +} + +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 0000000000000000000000000000000000000000..a2c25714964efa80c0a5f5580e12b360ac050844 --- /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 ( + <ReactModal + ref="modal" + style={this.props.modalStyle} + isOpen={this.props.visible} + onRequestClose={this.props.onRequestClose} + > + <div + onClick={this.props.onRequestClose} + style={this.props.closeButtonStyle} + > + x + </div> + {this.props.children} + </ReactModal> + ); + } +} + +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 a37213d9b25db729ea3736eab486ee28f942bc98..da00622bf8f21b0c8d3b2cb57576af7d3015eb91 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 { </option> ))} </select> + <p> + <button onClick={openNewSprite}>New sprite</button> + <button onClick={openNewCostume}>New costume</button> + <button onClick={openNewBackdrop}>New backdrop</button> + </p> </div> ); } @@ -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 0000000000000000000000000000000000000000..bcac13b5237e414dcf2174577075e7eff591394d --- /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 <LibaryComponent + title="Backdrop Library" + visible={this.props.visible} + data={this.state.backdropData} + onRequestClose={this.props.onRequestClose} + onItemSelected={this.selectItem} + />; + } +} + +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 0000000000000000000000000000000000000000..091a736ffbb6213b34d2402da3251d10e014c243 --- /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 <LibaryComponent + title="Costume Library" + visible={this.props.visible} + data={this.state.costumeData} + onRequestClose={this.props.onRequestClose} + onItemSelected={this.selectItem} + />; + } +} + +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 937f4446f9851bd928e2ebc6dcde377f58f08cbf..0ab742034fa6ff94d4efc6942cf42e2a70a548d1 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 ( <GUIComponent {... guiProps}> @@ -61,6 +99,9 @@ class GUI extends React.Component { <Stage vm={vm} {...stageProps} /> <SpriteSelector vm={vm} {... spriteSelectorProps} /> <Blocks vm={vm} {... blocksProps} /> + <SpriteLibrary vm={vm} {...spriteLibraryProps} /> + <CostumeLibrary vm={vm} {...costumeLibraryProps} /> + <BackdropLibrary vm={vm} {...backdropLibraryProps} /> </GUIComponent> ); } @@ -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 0000000000000000000000000000000000000000..602fb177609eb9e49351ef9753d929a569ec8ed6 --- /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 <LibaryComponent + title="Sprite Library" + visible={this.props.visible} + data={libraryData} + mediaLibrary={this.props.mediaLibrary} + onRequestClose={this.props.onRequestClose} + onItemSelected={this.selectItem} + />; + } +} + +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 2f5c83d44f702f089f5f7019c68772dbc5fbd0f4..c30ac713784551c4fc8b6bf7f8495e99354bea2a 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 ( <SpriteSelectorComponent value={this.state.targets.editingTarget && [this.state.targets.editingTarget]} onChange={this.onChange} + openNewSprite={openNewSprite} + openNewCostume={openNewCostume} + openNewBackdrop={openNewBackdrop} sprites={this.state.targets.targetList.map(target => ( { 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 0000000000000000000000000000000000000000..6f33b25c919a99d98abdafebc1a24a18f3eb0ce1 --- /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.<!string, 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;