Merge pull request #2407 from paulkaplan/backpack-step-5
Backpack step 5: saving costumes and sounds
This commit is contained in:
12 files changed
+260
-9
No files matched your search
@@ -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",
|
||||
|
||||
@@ -68,6 +68,7 @@ const Selector = props => {
|
||||
})}
|
||||
costumeURL={item.url}
|
||||
details={item.details}
|
||||
dragPayload={item.dragPayload}
|
||||
dragType={dragType}
|
||||
id={index}
|
||||
index={index}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}) => (
|
||||
<div className={styles.backpackContainer}>
|
||||
<div
|
||||
className={styles.backpackHeader}
|
||||
@@ -42,7 +43,10 @@ const Backpack = ({contents, error, expanded, loading, onToggle}) => (
|
||||
)}
|
||||
</div>
|
||||
{expanded ? (
|
||||
<div className={styles.backpackList}>
|
||||
<div
|
||||
className={styles.backpackList}
|
||||
ref={dropAreaRef}
|
||||
>
|
||||
{error ? (
|
||||
<div className={styles.statusMessage}>
|
||||
<FormattedMessage
|
||||
@@ -62,7 +66,11 @@ const Backpack = ({contents, error, expanded, loading, onToggle}) => (
|
||||
</div>
|
||||
) : (
|
||||
contents.length > 0 ? (
|
||||
<div className={styles.backpackListInner}>
|
||||
<div
|
||||
className={classNames(styles.backpackListInner, {
|
||||
[styles.dragOver]: dragOver
|
||||
})}
|
||||
>
|
||||
{contents.map(item => (
|
||||
<SpriteSelectorItem
|
||||
className={styles.backpackItem}
|
||||
@@ -70,10 +78,12 @@ const Backpack = ({contents, error, expanded, loading, onToggle}) => (
|
||||
details={item.name}
|
||||
dragPayload={item}
|
||||
dragType={dragTypeMap[item.type]}
|
||||
id={item.id}
|
||||
key={item.id}
|
||||
name={item.type}
|
||||
selected={false}
|
||||
onClick={noop}
|
||||
onDeleteButtonClick={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
<BackpackComponent
|
||||
contents={this.state.contents}
|
||||
dragOver={this.state.dragOver}
|
||||
dropAreaRef={this.setRef}
|
||||
error={this.state.error}
|
||||
expanded={this.state.expanded}
|
||||
loading={this.state.loading}
|
||||
onDelete={this.handleDelete}
|
||||
onToggle={this.props.host ? this.handleToggle : null}
|
||||
/>
|
||||
);
|
||||
@@ -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);
|
||||
@@ -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 (
|
||||
<AssetPanel
|
||||
|
||||
@@ -169,7 +169,8 @@ class SoundTab extends React.Component {
|
||||
{
|
||||
url: soundIcon,
|
||||
name: sound.name,
|
||||
details: (sound.sampleCount / sound.rate).toFixed(2)
|
||||
details: (sound.sampleCount / sound.rate).toFixed(2),
|
||||
dragPayload: sound
|
||||
}
|
||||
)) : [];
|
||||
|
||||
|
||||
+48
-1
@@ -1,4 +1,6 @@
|
||||
import xhr from 'xhr';
|
||||
import costumePayload from './backpack/costume-payload';
|
||||
import soundPayload from './backpack/sound-payload';
|
||||
|
||||
const getBackpackContents = ({
|
||||
host,
|
||||
@@ -24,6 +26,51 @@ const getBackpackContents = ({
|
||||
});
|
||||
});
|
||||
|
||||
const saveBackpackObject = ({
|
||||
host,
|
||||
username,
|
||||
token,
|
||||
type, // Type of object being saved to the backpack
|
||||
mime, // Mime-type of the object being saved
|
||||
name, // User-facing name of the object being saved
|
||||
body, // Base64-encoded body of the object being saved
|
||||
thumbnail // Base64-encoded JPEG thumbnail of the object being saved
|
||||
}) => 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
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
Reference in new issue
Block a user