) : (
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