diff --git a/package.json b/package.json index 6e29635ae..ff5ec086d 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "babel-plugin-transform-object-rest-spread": "^6.22.0", "babel-preset-env": "^1.6.1", "babel-preset-react": "^6.22.0", + "base64-loader": "1.0.0", "bowser": "1.9.3", "chromedriver": "2.40.0", "classnames": "2.2.6", diff --git a/src/components/asset-panel/selector.jsx b/src/components/asset-panel/selector.jsx index 2462986d8..fc509cc71 100644 --- a/src/components/asset-panel/selector.jsx +++ b/src/components/asset-panel/selector.jsx @@ -68,6 +68,7 @@ const Selector = props => { })} costumeURL={item.url} details={item.details} + dragPayload={item.dragPayload} dragType={dragType} id={index} index={index} diff --git a/src/components/backpack/backpack.css b/src/components/backpack/backpack.css index 229344a2d..9143844d0 100644 --- a/src/components/backpack/backpack.css +++ b/src/components/backpack/backpack.css @@ -42,6 +42,18 @@ overflow-x: auto; } +.drag-over:after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + opacity: 0.75; + background-color: #8cbcff; + transition: all 0.25s ease; +} + .status-message { width: 100%; text-align: center; diff --git a/src/components/backpack/backpack.jsx b/src/components/backpack/backpack.jsx index 563ec84a3..0f7b0b89f 100644 --- a/src/components/backpack/backpack.jsx +++ b/src/components/backpack/backpack.jsx @@ -1,5 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; +import classNames from 'classnames'; import {FormattedMessage} from 'react-intl'; import DragConstants from '../../lib/drag-constants'; import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx'; @@ -16,7 +17,7 @@ const dragTypeMap = { sprite: DragConstants.BACKPACK_SPRITE }; -const Backpack = ({contents, error, expanded, loading, onToggle}) => ( +const Backpack = ({contents, dragOver, dropAreaRef, error, expanded, loading, onToggle, onDelete}) => (
( )}
{expanded ? ( -
+
{error ? (
(
) : ( contents.length > 0 ? ( -
+
{contents.map(item => ( ( details={item.name} dragPayload={item} dragType={dragTypeMap[item.type]} + id={item.id} key={item.id} name={item.type} selected={false} onClick={noop} + onDeleteButtonClick={onDelete} /> ))}
@@ -100,14 +110,18 @@ Backpack.propTypes = { type: PropTypes.string, name: PropTypes.string })), + dragOver: PropTypes.bool, + dropAreaRef: PropTypes.func, error: PropTypes.bool, expanded: PropTypes.bool, loading: PropTypes.bool, + onDelete: PropTypes.func, onToggle: PropTypes.func }; Backpack.defaultProps = { contents: [], + dragOver: false, expanded: false, loading: false, onToggle: null diff --git a/src/containers/backpack.jsx b/src/containers/backpack.jsx index b6068cb5d..35fc11bf1 100644 --- a/src/containers/backpack.jsx +++ b/src/containers/backpack.jsx @@ -2,7 +2,15 @@ import React from 'react'; import PropTypes from 'prop-types'; import bindAll from 'lodash.bindall'; import BackpackComponent from '../components/backpack/backpack.jsx'; -import {getBackpackContents} from '../lib/backpack-api'; +import { + getBackpackContents, + saveBackpackObject, + deleteBackpackObject, + soundPayload, + costumePayload +} from '../lib/backpack-api'; +import DragConstants from '../lib/drag-constants'; + import {connect} from 'react-redux'; import storage from '../lib/storage'; @@ -10,10 +18,14 @@ class Backpack extends React.Component { constructor (props) { super(props); bindAll(this, [ + 'handleDrop', 'handleToggle', - 'refreshContents' + 'handleDelete', + 'refreshContents', + 'setRef' ]); this.state = { + dragOver: false, error: false, offset: 0, itemsPerPage: 20, @@ -32,6 +44,29 @@ class Backpack extends React.Component { storage._hasAddedBackpackSource = true; } } + componentWillReceiveProps (newProps) { + const dragTypes = [DragConstants.COSTUME, DragConstants.SOUND]; + // If `dragging` becomes true, record the drop area rectangle + if (newProps.dragInfo.dragging && !this.props.dragInfo.dragging) { + this.dropAreaRect = this.ref && this.ref.getBoundingClientRect(); + // If `dragging` becomes false, call the drop handler + } else if (!newProps.dragInfo.dragging && this.props.dragInfo.dragging && this.state.dragOver) { + this.handleDrop(this.props.dragInfo); + this.setState({dragOver: false}); + } + + // If a drag is in progress (currentOffset) and it matches the relevant drag types, + // test if the drag is within the drop area rect and set the state accordingly. + if (this.dropAreaRect && newProps.dragInfo.currentOffset && dragTypes.includes(newProps.dragInfo.dragType)) { + const {x, y} = newProps.dragInfo.currentOffset; + const {top, right, bottom, left} = this.dropAreaRect; + if (x > left && x < right && y > top && y < bottom) { + this.setState({dragOver: true}); + } else { + this.setState({dragOver: false}); + } + } + } handleToggle () { const newState = !this.state.expanded; this.setState({expanded: newState, offset: 0}); @@ -39,6 +74,35 @@ class Backpack extends React.Component { this.refreshContents(); } } + handleDrop (dragInfo) { + let payloader = null; + switch (dragInfo.dragType) { + case DragConstants.COSTUME: + payloader = costumePayload; + break; + case DragConstants.SOUND: + payloader = soundPayload; + break; + } + if (!payloader) return; + + payloader(dragInfo.payload) + .then(payload => saveBackpackObject({ + host: this.props.host, + token: this.props.token, + username: this.props.username, + ...payload + })) + .then(this.refreshContents); + } + handleDelete (id) { + deleteBackpackObject({ + host: this.props.host, + token: this.props.token, + username: this.props.username, + id: id + }).then(this.refreshContents); + } refreshContents () { if (this.props.token && this.props.username) { this.setState({loading: true, error: false}); @@ -57,13 +121,19 @@ class Backpack extends React.Component { }); } } + setRef (ref) { + this.ref = ref; + } render () { return ( ); @@ -71,12 +141,21 @@ class Backpack extends React.Component { } Backpack.propTypes = { + dragInfo: PropTypes.shape({ + currentOffset: PropTypes.shape({ + x: PropTypes.number, + y: PropTypes.number + }), + dragType: PropTypes.string, + dragging: PropTypes.bool, + index: PropTypes.number + }), host: PropTypes.string, token: PropTypes.string, username: PropTypes.string }; -const mapStateToProps = state => { +const getTokenAndUsername = state => { // Look for the session state provided by scratch-www if (state.session && state.session.session) { return { @@ -94,4 +173,12 @@ const mapStateToProps = state => { }; }; +const mapStateToProps = state => Object.assign( + { + dragInfo: state.scratchGui.assetDrag, + vm: state.scratchGui.vm + }, + getTokenAndUsername(state) +); + export default connect(mapStateToProps)(Backpack); diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx index cde662f08..248ce989b 100644 --- a/src/containers/costume-tab.jsx +++ b/src/containers/costume-tab.jsx @@ -253,7 +253,8 @@ class CostumeTab extends React.Component { const costumeData = target.costumes ? target.costumes.map(costume => ({ name: costume.name, assetId: costume.assetId, - details: costume.size ? this.formatCostumeDetails(costume.size, costume.bitmapResolution) : null + details: costume.size ? this.formatCostumeDetails(costume.size, costume.bitmapResolution) : null, + dragPayload: costume })) : []; return ( new Promise((resolve, reject) => { + xhr({ + method: 'POST', + uri: `${host}${username}`, + headers: {'x-token': token}, + json: {type, mime, name, body, thumbnail} + }, (error, response) => { + if (error || response.statusCode !== 200) { + return reject(); + } + return resolve(response.body); + }); +}); + +const deleteBackpackObject = ({ + host, + username, + token, + id +}) => new Promise((resolve, reject) => { + xhr({ + method: 'DELETE', + uri: `${host}${username}/${id}`, + headers: {'x-token': token} + }, (error, response) => { + if (error || response.statusCode !== 200) { + return reject(); + } + return resolve(response.body); + }); +}); + export { - getBackpackContents + getBackpackContents, + saveBackpackObject, + deleteBackpackObject, + costumePayload, + soundPayload }; diff --git a/src/lib/backpack/costume-payload.js b/src/lib/backpack/costume-payload.js new file mode 100644 index 000000000..9786ec8d3 --- /dev/null +++ b/src/lib/backpack/costume-payload.js @@ -0,0 +1,37 @@ +import jpegThumbnail from './jpeg-thumbnail'; +import storage from '../storage'; + +const costumePayload = costume => { + // TODO is it ok to base64 encode SVGs? What about unicode text inside them? + const assetDataUrl = storage.get(costume.assetId).encodeDataURI(); + const assetDataFormat = costume.dataFormat; + const payload = { + type: 'costume', + name: costume.name, + // Params to be filled in below + mime: '', + body: '', + thumbnail: '' + }; + + switch (assetDataFormat) { + case 'svg': + payload.mime = 'image/svg+xml'; + payload.body = assetDataUrl.replace('data:image/svg+xml;base64,', ''); + break; + case 'png': + payload.mime = 'image/png'; + payload.body = assetDataUrl.replace('data:image/png;base64,', ''); + break; + default: + alert(`Cannot serialize for format: ${assetDataFormat}`); // eslint-disable-line + } + + // TODO we may be able to optimize by not actually generating thumbnails... + return jpegThumbnail(assetDataUrl).then(thumbnail => { + payload.thumbnail = thumbnail.replace('data:image/jpeg;base64,', ''); + return payload; + }); +}; + +export default costumePayload; diff --git a/src/lib/backpack/jpeg-thumbnail.js b/src/lib/backpack/jpeg-thumbnail.js new file mode 100644 index 000000000..623daf3be --- /dev/null +++ b/src/lib/backpack/jpeg-thumbnail.js @@ -0,0 +1,20 @@ +const jpegThumbnail = dataUrl => new Promise(resolve => { + const image = new Image(); + image.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + // TODO we may want to draw to a smaller, fixed size to optimize file size + canvas.width = image.width; + canvas.height = image.height; + + ctx.fillStyle = 'white'; // Create white background, since jpeg doesn't have transparency + ctx.fillRect(0, 0, canvas.width, canvas.height); + + ctx.drawImage(image, 0, 0); + // TODO we can play with the `quality` option here to optimize file size + resolve(canvas.toDataURL('image/jpeg', 0.92 /* quality */)); // Default quality is 0.92 + }; + image.src = dataUrl; +}); + +export default jpegThumbnail; diff --git a/src/lib/backpack/sound-payload.js b/src/lib/backpack/sound-payload.js new file mode 100644 index 000000000..7ae573648 --- /dev/null +++ b/src/lib/backpack/sound-payload.js @@ -0,0 +1,30 @@ +// eslint-disable-next-line import/no-unresolved +import soundThumbnail from '!base64-loader!./sound-thumbnail.jpg'; +import storage from '../storage'; + +const soundPayload = sound => { + const assetDataUrl = storage.get(sound.assetId).encodeDataURI(); + const assetDataFormat = sound.dataFormat; + const payload = { + type: 'sound', + name: sound.name, + thumbnail: soundThumbnail, + // Params to be filled in below + mime: '', + body: '' + }; + + switch (assetDataFormat) { + case 'wav': + payload.mime = 'audio/x-wav'; + payload.body = assetDataUrl.replace('data:audio/x-wav;base64,', ''); + break; + default: + alert(`Cannot serialize for format: ${assetDataFormat}`); // eslint-disable-line + } + + // Return a promise to make it consistent with other payload constructors like costume-payload + return new Promise(resolve => resolve(payload)); +}; + +export default soundPayload; diff --git a/src/lib/backpack/sound-thumbnail.jpg b/src/lib/backpack/sound-thumbnail.jpg new file mode 100644 index 000000000..f709aaa13 Binary files /dev/null and b/src/lib/backpack/sound-thumbnail.jpg differ