Merge pull request #2407 from paulkaplan/backpack-step-5

Backpack step 5: saving costumes and sounds
This commit is contained in:
Paul Kaplan authored and GitHub committed 2018-06-28 08:44:11 -04:00
commit bf025f53c8
12 files changed
+260 -9

No files matched your search

+1
View File
@@ -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",
+1
View File
@@ -68,6 +68,7 @@ const Selector = props => {
})}
costumeURL={item.url}
details={item.details}
dragPayload={item.dragPayload}
dragType={dragType}
id={index}
index={index}
+12
View File
@@ -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;
+17 -3
View File
@@ -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
+90 -3
View File
@@ -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);
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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
};
+37
View File
@@ -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;
+20
View File
@@ -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;
+30
View File
@@ -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