Merge pull request #2323 from paulkaplan/reorderable-sprites
Make costumes, sounds and sprites sortable
This commit is contained in:
18 files changed
+536
-77
No files matched your search
@@ -47,12 +47,14 @@ $fade-out-distance: 100px;
|
||||
height: 0;
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
width: 5rem;
|
||||
min-height: 5rem;
|
||||
margin: 1rem auto;
|
||||
margin: 0.5rem auto;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $full-size-paint) {
|
||||
@@ -64,3 +66,9 @@ $fade-out-distance: 100px;
|
||||
width: 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.list-item.placeholder {
|
||||
background: black;
|
||||
filter: opacity(15%) brightness(20%);
|
||||
}
|
||||
@@ -1,22 +1,33 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import SpriteSelectorItem from '../../containers/sprite-selector-item.jsx';
|
||||
|
||||
import Box from '../box/box.jsx';
|
||||
import ActionMenu from '../action-menu/action-menu.jsx';
|
||||
import SortableAsset from './sortable-asset.jsx';
|
||||
import SortableHOC from '../../lib/sortable-hoc.jsx';
|
||||
import DragConstants from '../../lib/drag-constants';
|
||||
|
||||
import styles from './selector.css';
|
||||
|
||||
const Selector = props => {
|
||||
const {
|
||||
buttons,
|
||||
dragType,
|
||||
items,
|
||||
selectedItemIndex,
|
||||
draggingIndex,
|
||||
draggingType,
|
||||
ordering,
|
||||
onAddSortable,
|
||||
onRemoveSortable,
|
||||
onDeleteClick,
|
||||
onDuplicateClick,
|
||||
onItemClick
|
||||
} = props;
|
||||
|
||||
const isRelevantDrag = draggingType === dragType;
|
||||
|
||||
let newButtonSection = null;
|
||||
|
||||
if (buttons.length > 0) {
|
||||
@@ -38,20 +49,31 @@ const Selector = props => {
|
||||
<Box className={styles.wrapper}>
|
||||
<Box className={styles.listArea}>
|
||||
{items.map((item, index) => (
|
||||
<SpriteSelectorItem
|
||||
assetId={item.assetId}
|
||||
className={styles.listItem}
|
||||
costumeURL={item.url}
|
||||
details={item.details}
|
||||
id={index}
|
||||
key={`asset-${index}`}
|
||||
name={item.name}
|
||||
number={index + 1 /* 1-indexed */}
|
||||
selected={index === selectedItemIndex}
|
||||
onClick={onItemClick}
|
||||
onDeleteButtonClick={onDeleteClick}
|
||||
onDuplicateButtonClick={onDuplicateClick}
|
||||
/>
|
||||
<SortableAsset
|
||||
id={item.name}
|
||||
index={isRelevantDrag ? ordering.indexOf(index) : index}
|
||||
key={item.name}
|
||||
onAddSortable={onAddSortable}
|
||||
onRemoveSortable={onRemoveSortable}
|
||||
>
|
||||
<SpriteSelectorItem
|
||||
assetId={item.assetId}
|
||||
className={classNames(styles.listItem, {
|
||||
[styles.placeholder]: isRelevantDrag && index === draggingIndex
|
||||
})}
|
||||
costumeURL={item.url}
|
||||
details={item.details}
|
||||
dragType={dragType}
|
||||
id={index}
|
||||
index={index}
|
||||
name={item.name}
|
||||
number={index + 1 /* 1-indexed */}
|
||||
selected={index === selectedItemIndex}
|
||||
onClick={onItemClick}
|
||||
onDeleteButtonClick={onDeleteClick}
|
||||
onDuplicateButtonClick={onDuplicateClick}
|
||||
/>
|
||||
</SortableAsset>
|
||||
))}
|
||||
</Box>
|
||||
{newButtonSection}
|
||||
@@ -65,14 +87,20 @@ Selector.propTypes = {
|
||||
img: PropTypes.string.isRequired,
|
||||
onClick: PropTypes.func
|
||||
})),
|
||||
dragType: PropTypes.oneOf(Object.keys(DragConstants)),
|
||||
draggingIndex: PropTypes.number,
|
||||
draggingType: PropTypes.oneOf(Object.keys(DragConstants)),
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
url: PropTypes.string,
|
||||
name: PropTypes.string.isRequired
|
||||
})),
|
||||
onAddSortable: PropTypes.func,
|
||||
onDeleteClick: PropTypes.func,
|
||||
onDuplicateClick: PropTypes.func,
|
||||
onItemClick: PropTypes.func.isRequired,
|
||||
onRemoveSortable: PropTypes.func,
|
||||
ordering: PropTypes.arrayOf(PropTypes.number),
|
||||
selectedItemIndex: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default Selector;
|
||||
export default SortableHOC(Selector);
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
import bindAll from 'lodash.bindall';
|
||||
|
||||
class SortableAsset extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'setRef'
|
||||
]);
|
||||
}
|
||||
componentDidMount () {
|
||||
this.props.onAddSortable(this.ref);
|
||||
}
|
||||
componentWillUnmount () {
|
||||
this.props.onRemoveSortable(this.ref);
|
||||
}
|
||||
setRef (ref) {
|
||||
this.ref = ref;
|
||||
}
|
||||
render () {
|
||||
return (
|
||||
<div
|
||||
className={this.props.className}
|
||||
ref={this.setRef}
|
||||
style={{
|
||||
order: this.props.index
|
||||
}}
|
||||
>
|
||||
{...this.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SortableAsset.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
className: PropTypes.string,
|
||||
index: PropTypes.number.isRequired,
|
||||
onAddSortable: PropTypes.func.isRequired,
|
||||
onRemoveSortable: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default SortableAsset;
|
||||
@@ -19,11 +19,19 @@
|
||||
|
||||
.image {
|
||||
max-width: 80px;
|
||||
max-height: 80px;
|
||||
min-width: 50px;
|
||||
min-height: 50px;
|
||||
|
||||
/* Center the dragging image on the given position */
|
||||
margin-left: -50%;
|
||||
margin-top: -50%;
|
||||
|
||||
padding: 0.25rem;
|
||||
border: 2px solid $motion-primary;
|
||||
background: $ui-white;
|
||||
border-radius: 0.5rem;
|
||||
|
||||
/* Use the same drop shadow as stage dragging */
|
||||
filter: drop-shadow(5px 5px 5px $ui-black-transparent);
|
||||
box-shadow: 5px 5px 5px $ui-black-transparent;
|
||||
}
|
||||
@@ -19,6 +19,8 @@
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: 0.25s ease-out;
|
||||
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sprite-selector-item.is-selected {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import DragConstants from '../../lib/drag-constants';
|
||||
|
||||
import Box from '../box/box.jsx';
|
||||
import SpriteSelectorItem from '../../containers/sprite-selector-item.jsx';
|
||||
import SortableHOC from '../../lib/sortable-hoc.jsx';
|
||||
import SortableAsset from '../asset-panel/sortable-asset.jsx';
|
||||
|
||||
import styles from './sprite-selector.css';
|
||||
|
||||
const SpriteList = function (props) {
|
||||
const {
|
||||
editingTarget,
|
||||
draggingIndex,
|
||||
draggingType,
|
||||
hoveredTarget,
|
||||
onDeleteSprite,
|
||||
onDuplicateSprite,
|
||||
onSelectSprite,
|
||||
onAddSortable,
|
||||
onRemoveSortable,
|
||||
ordering,
|
||||
raised,
|
||||
selectedId,
|
||||
items
|
||||
} = props;
|
||||
|
||||
const isSpriteDrag = draggingType === DragConstants.SPRITE;
|
||||
|
||||
return (
|
||||
<Box className={styles.itemsWrapper}>
|
||||
{items.map((sprite, index) => {
|
||||
|
||||
// If the sprite has just received a block drop, used for green highlight
|
||||
const receivedBlocks = (
|
||||
hoveredTarget.sprite === sprite.id &&
|
||||
sprite.id !== editingTarget &&
|
||||
hoveredTarget.receivedBlocks
|
||||
);
|
||||
|
||||
// If the sprite is indicating it can receive block dropping, used for blue highlight
|
||||
const isRaised = !receivedBlocks && raised && sprite.id !== editingTarget;
|
||||
|
||||
return (
|
||||
<SortableAsset
|
||||
className={classNames(styles.itemWrapper, {
|
||||
[styles.placeholder]: isSpriteDrag && index === draggingIndex})}
|
||||
index={isSpriteDrag ? ordering.indexOf(index) : index}
|
||||
key={sprite.name}
|
||||
onAddSortable={onAddSortable}
|
||||
onRemoveSortable={onRemoveSortable}
|
||||
>
|
||||
<SpriteSelectorItem
|
||||
assetId={sprite.costume && sprite.costume.assetId}
|
||||
className={classNames(styles.sprite, {
|
||||
[styles.raised]: isRaised,
|
||||
[styles.receivedBlocks]: receivedBlocks
|
||||
})}
|
||||
dragType={DragConstants.SPRITE}
|
||||
id={sprite.id}
|
||||
index={index}
|
||||
key={sprite.id}
|
||||
name={sprite.name}
|
||||
selected={sprite.id === selectedId}
|
||||
onClick={onSelectSprite}
|
||||
onDeleteButtonClick={onDeleteSprite}
|
||||
onDuplicateButtonClick={onDuplicateSprite}
|
||||
/>
|
||||
</SortableAsset>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
SpriteList.propTypes = {
|
||||
draggingIndex: PropTypes.number,
|
||||
draggingType: PropTypes.oneOf(Object.keys(DragConstants)),
|
||||
editingTarget: PropTypes.string,
|
||||
hoveredTarget: PropTypes.shape({
|
||||
hoveredSprite: PropTypes.string,
|
||||
receivedBlocks: PropTypes.bool
|
||||
}),
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
costume: PropTypes.shape({
|
||||
url: PropTypes.string,
|
||||
name: PropTypes.string.isRequired,
|
||||
bitmapResolution: PropTypes.number.isRequired,
|
||||
rotationCenterX: PropTypes.number.isRequired,
|
||||
rotationCenterY: PropTypes.number.isRequired
|
||||
}),
|
||||
name: PropTypes.string.isRequired,
|
||||
order: PropTypes.number.isRequired
|
||||
})),
|
||||
onAddSortable: PropTypes.func,
|
||||
onDeleteSprite: PropTypes.func,
|
||||
onDuplicateSprite: PropTypes.func,
|
||||
onRemoveSortable: PropTypes.func,
|
||||
onSelectSprite: PropTypes.func,
|
||||
ordering: PropTypes.arrayOf(PropTypes.number),
|
||||
raised: PropTypes.bool,
|
||||
selectedId: PropTypes.string
|
||||
};
|
||||
|
||||
export default SortableHOC(SpriteList);
|
||||
@@ -104,3 +104,8 @@
|
||||
90% { box-shadow: 0 0 10px #7fff1e; }
|
||||
100% { box-shadow: none; }
|
||||
}
|
||||
|
||||
.placeholder > .sprite {
|
||||
background: black;
|
||||
filter: opacity(15%) brightness(20%);
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import {defineMessages, injectIntl, intlShape} from 'react-intl';
|
||||
|
||||
|
||||
import Box from '../box/box.jsx';
|
||||
import SpriteInfo from '../../containers/sprite-info.jsx';
|
||||
import SpriteSelectorItem from '../../containers/sprite-selector-item.jsx';
|
||||
import SpriteList from './sprite-list.jsx';
|
||||
import ActionMenu from '../action-menu/action-menu.jsx';
|
||||
import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants';
|
||||
|
||||
@@ -50,6 +50,7 @@ const SpriteSelectorComponent = function (props) {
|
||||
onChangeSpriteVisibility,
|
||||
onChangeSpriteX,
|
||||
onChangeSpriteY,
|
||||
onDrop,
|
||||
onDeleteSprite,
|
||||
onDuplicateSprite,
|
||||
onFileUploadClick,
|
||||
@@ -95,31 +96,17 @@ const SpriteSelectorComponent = function (props) {
|
||||
/>
|
||||
|
||||
<Box className={styles.scrollWrapper}>
|
||||
<Box className={styles.itemsWrapper}>
|
||||
{Object.keys(sprites)
|
||||
// Re-order by list order
|
||||
.sort((id1, id2) => sprites[id1].order - sprites[id2].order)
|
||||
.map(id => sprites[id])
|
||||
.map(sprite => (
|
||||
<SpriteSelectorItem
|
||||
assetId={sprite.costume && sprite.costume.assetId}
|
||||
className={hoveredTarget.sprite === sprite.id &&
|
||||
sprite.id !== editingTarget &&
|
||||
hoveredTarget.receivedBlocks ?
|
||||
classNames(styles.sprite, styles.receivedBlocks) :
|
||||
raised && sprite.id !== editingTarget ?
|
||||
classNames(styles.sprite, styles.raised) : styles.sprite}
|
||||
id={sprite.id}
|
||||
key={sprite.id}
|
||||
name={sprite.name}
|
||||
selected={sprite.id === selectedId}
|
||||
onClick={onSelectSprite}
|
||||
onDeleteButtonClick={onDeleteSprite}
|
||||
onDuplicateButtonClick={onDuplicateSprite}
|
||||
/>
|
||||
))
|
||||
}
|
||||
</Box>
|
||||
<SpriteList
|
||||
editingTarget={editingTarget}
|
||||
hoveredTarget={hoveredTarget}
|
||||
items={Object.keys(sprites).map(id => sprites[id])}
|
||||
raised={raised}
|
||||
selectedId={selectedId}
|
||||
onDeleteSprite={onDeleteSprite}
|
||||
onDrop={onDrop}
|
||||
onDuplicateSprite={onDuplicateSprite}
|
||||
onSelectSprite={onSelectSprite}
|
||||
/>
|
||||
</Box>
|
||||
<ActionMenu
|
||||
className={styles.addButton}
|
||||
@@ -163,6 +150,7 @@ SpriteSelectorComponent.propTypes = {
|
||||
onChangeSpriteX: PropTypes.func,
|
||||
onChangeSpriteY: PropTypes.func,
|
||||
onDeleteSprite: PropTypes.func,
|
||||
onDrop: PropTypes.func,
|
||||
onDuplicateSprite: PropTypes.func,
|
||||
onFileUploadClick: PropTypes.func,
|
||||
onNewSpriteClick: PropTypes.func,
|
||||
|
||||
@@ -28,6 +28,7 @@ const TargetPane = ({
|
||||
onChangeSpriteX,
|
||||
onChangeSpriteY,
|
||||
onDeleteSprite,
|
||||
onDrop,
|
||||
onDuplicateSprite,
|
||||
onFileUploadClick,
|
||||
onNewSpriteClick,
|
||||
@@ -63,6 +64,7 @@ const TargetPane = ({
|
||||
onChangeSpriteX={onChangeSpriteX}
|
||||
onChangeSpriteY={onChangeSpriteY}
|
||||
onDeleteSprite={onDeleteSprite}
|
||||
onDrop={onDrop}
|
||||
onDuplicateSprite={onDuplicateSprite}
|
||||
onFileUploadClick={onFileUploadClick}
|
||||
onNewSpriteClick={onNewSpriteClick}
|
||||
@@ -129,6 +131,7 @@ TargetPane.propTypes = {
|
||||
onChangeSpriteX: PropTypes.func,
|
||||
onChangeSpriteY: PropTypes.func,
|
||||
onDeleteSprite: PropTypes.func,
|
||||
onDrop: PropTypes.func,
|
||||
onDuplicateSprite: PropTypes.func,
|
||||
onFileUploadClick: PropTypes.func,
|
||||
onNewSpriteClick: PropTypes.func,
|
||||
|
||||
@@ -10,6 +10,7 @@ import CameraModal from './camera-modal.jsx';
|
||||
import {connect} from 'react-redux';
|
||||
import {handleFileUpload, costumeUpload} from '../lib/file-uploader.js';
|
||||
import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx';
|
||||
import DragConstants from '../lib/drag-constants';
|
||||
|
||||
import {
|
||||
closeCameraCapture,
|
||||
@@ -80,6 +81,7 @@ class CostumeTab extends React.Component {
|
||||
'handleFileUploadClick',
|
||||
'handleCostumeUpload',
|
||||
'handleCameraBuffer',
|
||||
'handleDrop',
|
||||
'setFileInput'
|
||||
]);
|
||||
const {
|
||||
@@ -188,6 +190,17 @@ class CostumeTab extends React.Component {
|
||||
handleFileUploadClick () {
|
||||
this.fileInput.click();
|
||||
}
|
||||
handleDrop (dropInfo) {
|
||||
// Eventually will handle other kinds of drop events, right now just
|
||||
// the reordering events.
|
||||
if (dropInfo.dragType === DragConstants.COSTUME) {
|
||||
const sprite = this.props.vm.editingTarget.sprite;
|
||||
const activeCostume = sprite.costumes[this.state.selectedCostumeIndex];
|
||||
this.props.vm.reorderCostume(this.props.vm.editingTarget.id,
|
||||
dropInfo.index, dropInfo.newIndex);
|
||||
this.setState({selectedCostumeIndex: sprite.costumes.indexOf(activeCostume)});
|
||||
}
|
||||
}
|
||||
setFileInput (input) {
|
||||
this.fileInput = input;
|
||||
}
|
||||
@@ -207,29 +220,27 @@ class CostumeTab extends React.Component {
|
||||
onNewLibraryCostumeClick,
|
||||
cameraModalVisible,
|
||||
onRequestCloseCameraModal,
|
||||
editingTarget,
|
||||
sprites,
|
||||
stage
|
||||
vm
|
||||
} = this.props;
|
||||
|
||||
const target = editingTarget && sprites[editingTarget] ? sprites[editingTarget] : stage;
|
||||
|
||||
if (!target) {
|
||||
if (!vm.editingTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const addLibraryMessage = target.isStage ? messages.addLibraryBackdropMsg : messages.addLibraryCostumeMsg;
|
||||
const addFileMessage = target.isStage ? messages.addFileBackdropMsg : messages.addFileCostumeMsg;
|
||||
const addSurpriseFunc = target.isStage ? this.handleSurpriseBackdrop : this.handleSurpriseCostume;
|
||||
const addLibraryFunc = target.isStage ? onNewLibraryBackdropClick : onNewLibraryCostumeClick;
|
||||
const addLibraryIcon = target.isStage ? addLibraryBackdropIcon : addLibraryCostumeIcon;
|
||||
const isStage = vm.editingTarget.isStage;
|
||||
const target = vm.editingTarget.sprite;
|
||||
|
||||
const costumeData = (target.costumes || []).map(costume => ({
|
||||
const addLibraryMessage = isStage ? messages.addLibraryBackdropMsg : messages.addLibraryCostumeMsg;
|
||||
const addFileMessage = isStage ? messages.addFileBackdropMsg : messages.addFileCostumeMsg;
|
||||
const addSurpriseFunc = isStage ? this.handleSurpriseBackdrop : this.handleSurpriseCostume;
|
||||
const addLibraryFunc = isStage ? onNewLibraryBackdropClick : onNewLibraryCostumeClick;
|
||||
const addLibraryIcon = isStage ? addLibraryBackdropIcon : addLibraryCostumeIcon;
|
||||
|
||||
const costumeData = target.costumes ? target.costumes.map(costume => ({
|
||||
name: costume.name,
|
||||
assetId: costume.assetId,
|
||||
details: costume.size ? this.formatCostumeDetails(costume.size, costume.bitmapResolution) : null
|
||||
}));
|
||||
|
||||
})) : [];
|
||||
return (
|
||||
<AssetPanel
|
||||
buttons={[
|
||||
@@ -262,10 +273,12 @@ class CostumeTab extends React.Component {
|
||||
onClick: this.handleNewBlankCostume
|
||||
}
|
||||
]}
|
||||
dragType={DragConstants.COSTUME}
|
||||
items={costumeData}
|
||||
selectedItemIndex={this.state.selectedCostumeIndex}
|
||||
onDeleteClick={target && target.costumes && target.costumes.length > 1 ?
|
||||
this.handleDeleteCostume : null}
|
||||
onDrop={this.handleDrop}
|
||||
onDuplicateClick={this.handleDuplicateCostume}
|
||||
onItemClick={this.handleSelectCostume}
|
||||
>
|
||||
@@ -315,6 +328,7 @@ const mapStateToProps = state => ({
|
||||
editingTarget: state.scratchGui.targets.editingTarget,
|
||||
sprites: state.scratchGui.targets.sprites,
|
||||
stage: state.scratchGui.targets.stage,
|
||||
dragging: state.scratchGui.assetDrag.dragging,
|
||||
cameraModalVisible: state.scratchGui.modals.cameraCapture
|
||||
});
|
||||
|
||||
|
||||
@@ -43,7 +43,6 @@ class PaintEditorWrapper extends React.Component {
|
||||
return (
|
||||
<PaintEditor
|
||||
{...this.props}
|
||||
image={this.props.vm.getCostume(this.props.selectedCostumeIndex)}
|
||||
onUpdateImage={this.handleUpdateImage}
|
||||
onUpdateName={this.handleUpdateName}
|
||||
/>
|
||||
@@ -62,19 +61,19 @@ PaintEditorWrapper.propTypes = {
|
||||
};
|
||||
|
||||
const mapStateToProps = (state, {selectedCostumeIndex}) => {
|
||||
const {
|
||||
editingTarget,
|
||||
sprites,
|
||||
stage
|
||||
} = state.scratchGui.targets;
|
||||
const target = editingTarget && sprites[editingTarget] ? sprites[editingTarget] : stage;
|
||||
const costume = target && target.costumes[selectedCostumeIndex];
|
||||
const targetId = state.scratchGui.vm.editingTarget.id;
|
||||
const sprite = state.scratchGui.vm.editingTarget.sprite;
|
||||
// Make sure the costume index doesn't go out of range.
|
||||
const index = selectedCostumeIndex < sprite.costumes.length ?
|
||||
selectedCostumeIndex : sprite.costumes.length - 1;
|
||||
const costume = state.scratchGui.vm.editingTarget.sprite.costumes[index];
|
||||
return {
|
||||
name: costume && costume.name,
|
||||
rotationCenterX: costume && costume.rotationCenterX,
|
||||
rotationCenterY: costume && costume.rotationCenterY,
|
||||
imageFormat: costume && costume.dataFormat,
|
||||
imageId: editingTarget && `${editingTarget}${costume.skinId}`,
|
||||
imageId: targetId && `${targetId}${costume.skinId}`,
|
||||
image: state.scratchGui.vm.getCostume(index),
|
||||
vm: state.scratchGui.vm
|
||||
};
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ import SoundLibrary from './sound-library.jsx';
|
||||
import soundLibraryContent from '../lib/libraries/sounds.json';
|
||||
import {handleFileUpload, soundUpload} from '../lib/file-uploader.js';
|
||||
import errorBoundaryHOC from '../lib/error-boundary-hoc.jsx';
|
||||
import DragConstants from '../lib/drag-constants';
|
||||
|
||||
import {connect} from 'react-redux';
|
||||
|
||||
@@ -38,6 +39,7 @@ class SoundTab extends React.Component {
|
||||
'handleSurpriseSound',
|
||||
'handleFileUploadClick',
|
||||
'handleSoundUpload',
|
||||
'handleDrop',
|
||||
'setFileInput'
|
||||
]);
|
||||
this.state = {selectedSoundIndex: 0};
|
||||
@@ -117,6 +119,20 @@ class SoundTab extends React.Component {
|
||||
});
|
||||
}
|
||||
|
||||
handleDrop (dropInfo) {
|
||||
// Eventually will handle other kinds of drop events, right now just
|
||||
// the reordering events.
|
||||
if (dropInfo.dragType === DragConstants.SOUND) {
|
||||
const sprite = this.props.vm.editingTarget.sprite;
|
||||
const activeSound = sprite.sounds[this.state.selectedSoundIndex];
|
||||
|
||||
this.props.vm.reorderSound(this.props.vm.editingTarget.id,
|
||||
dropInfo.index, dropInfo.newIndex);
|
||||
|
||||
this.setState({selectedSoundIndex: sprite.sounds.indexOf(activeSound)});
|
||||
}
|
||||
}
|
||||
|
||||
setFileInput (input) {
|
||||
this.fileInput = input;
|
||||
}
|
||||
@@ -188,12 +204,11 @@ class SoundTab extends React.Component {
|
||||
img: addSoundFromRecordingIcon,
|
||||
onClick: onNewSoundFromRecordingClick
|
||||
}]}
|
||||
items={sounds.map(sound => ({
|
||||
url: soundIcon,
|
||||
...sound
|
||||
}))}
|
||||
dragType={DragConstants.SOUND}
|
||||
items={sounds}
|
||||
selectedItemIndex={this.state.selectedSoundIndex}
|
||||
onDeleteClick={this.handleDeleteSound}
|
||||
onDrop={this.handleDrop}
|
||||
onDuplicateClick={this.handleDuplicateSound}
|
||||
onItemClick={this.handleSelectSound}
|
||||
>
|
||||
|
||||
@@ -34,7 +34,12 @@ class SpriteSelectorItem extends React.Component {
|
||||
this.props.onDrag({
|
||||
img: null,
|
||||
currentOffset: null,
|
||||
dragging: false
|
||||
dragging: false,
|
||||
dragType: null,
|
||||
index: null
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.noClick = false;
|
||||
});
|
||||
}
|
||||
handleMouseMove (e) {
|
||||
@@ -45,8 +50,11 @@ class SpriteSelectorItem extends React.Component {
|
||||
this.props.onDrag({
|
||||
img: this.props.costumeURL,
|
||||
currentOffset: currentOffset,
|
||||
dragging: true
|
||||
dragging: true,
|
||||
dragType: this.props.dragType,
|
||||
index: this.props.index
|
||||
});
|
||||
this.noClick = true;
|
||||
}
|
||||
e.preventDefault();
|
||||
}
|
||||
@@ -59,7 +67,9 @@ class SpriteSelectorItem extends React.Component {
|
||||
}
|
||||
handleClick (e) {
|
||||
e.preventDefault();
|
||||
this.props.onClick(this.props.id);
|
||||
if (!this.noClick) {
|
||||
this.props.onClick(this.props.id);
|
||||
}
|
||||
}
|
||||
handleDelete (e) {
|
||||
e.stopPropagation(); // To prevent from bubbling back to handleClick
|
||||
@@ -84,6 +94,7 @@ class SpriteSelectorItem extends React.Component {
|
||||
/* eslint-disable no-unused-vars */
|
||||
assetId,
|
||||
id,
|
||||
index,
|
||||
onClick,
|
||||
onDeleteButtonClick,
|
||||
onDuplicateButtonClick,
|
||||
@@ -109,7 +120,9 @@ SpriteSelectorItem.propTypes = {
|
||||
assetId: PropTypes.string,
|
||||
costumeURL: PropTypes.string,
|
||||
dispatchSetHoveredSprite: PropTypes.func.isRequired,
|
||||
dragType: PropTypes.string,
|
||||
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
||||
index: PropTypes.number,
|
||||
name: PropTypes.string,
|
||||
onClick: PropTypes.func,
|
||||
onDeleteButtonClick: PropTypes.func,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
|
||||
import {activateTab, COSTUMES_TAB_INDEX} from '../reducers/editor-tab';
|
||||
import {setReceivedBlocks} from '../reducers/hovered-target';
|
||||
|
||||
import DragConstants from '../lib/drag-constants';
|
||||
import TargetPaneComponent from '../components/target-pane/target-pane.jsx';
|
||||
import spriteLibraryContent from '../lib/libraries/sprites.json';
|
||||
import {handleFileUpload, spriteUpload} from '../lib/file-uploader.js';
|
||||
@@ -27,6 +27,7 @@ class TargetPane extends React.Component {
|
||||
'handleChangeSpriteX',
|
||||
'handleChangeSpriteY',
|
||||
'handleDeleteSprite',
|
||||
'handleDrop',
|
||||
'handleDuplicateSprite',
|
||||
'handleNewSprite',
|
||||
'handleSelectSprite',
|
||||
@@ -106,6 +107,14 @@ class TargetPane extends React.Component {
|
||||
this.props.onReceivedBlocks(true);
|
||||
}
|
||||
}
|
||||
|
||||
handleDrop (dragInfo) {
|
||||
if (dragInfo.dragType === DragConstants.SPRITE) {
|
||||
// Add one to both new and target index because we are not counting/moving the stage
|
||||
this.props.vm.reorderTarget(dragInfo.index + 1, dragInfo.newIndex + 1);
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const {
|
||||
onActivateTab, // eslint-disable-line no-unused-vars
|
||||
@@ -123,6 +132,7 @@ class TargetPane extends React.Component {
|
||||
onChangeSpriteX={this.handleChangeSpriteX}
|
||||
onChangeSpriteY={this.handleChangeSpriteY}
|
||||
onDeleteSprite={this.handleDeleteSprite}
|
||||
onDrop={this.handleDrop}
|
||||
onDuplicateSprite={this.handleDuplicateSprite}
|
||||
onFileUploadClick={this.handleFileUploadClick}
|
||||
onPaintSpriteClick={this.handlePaintSpriteClick}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
SOUND: 'SOUND',
|
||||
COSTUME: 'COSTUME',
|
||||
SPRITE: 'SPRITE'
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* @fileoverview
|
||||
* Utility functions for drag interactions, e.g. sorting items in a grid/list.
|
||||
*/
|
||||
|
||||
/**
|
||||
* From an xy position and a list of boxes {top, left, bottom, right}, return there
|
||||
* corresponding box index the position is over. The boxes are in a (possibly wrapped)
|
||||
* list, the only requirement being all boxes are flush against the edges, that is,
|
||||
* if they are along an outer edge, the position of that edge is identical.
|
||||
* This functionality works for a single column of items, a wrapped list with
|
||||
* many rows, or a single row of items.
|
||||
* @param {{x: number, y: number}} position The xy coordinates to retreive the corresponding index of.
|
||||
* @param {Array.<DOMRect>} boxes The rects of the items, returned from `getBoundingClientRect`
|
||||
* @return {?number} index of the corresponding box, or null if one could not be found.
|
||||
*/
|
||||
const indexForPositionOnList = ({x, y}, boxes) => {
|
||||
if (boxes.length === 0) return null;
|
||||
let index = null;
|
||||
const leftEdge = Math.min.apply(null, boxes.map(b => b.left));
|
||||
const rightEdge = Math.max.apply(null, boxes.map(b => b.right));
|
||||
const topEdge = Math.min.apply(null, boxes.map(b => b.top));
|
||||
const bottomEdge = Math.max.apply(null, boxes.map(b => b.bottom));
|
||||
for (let n = 0; n < boxes.length; n++) {
|
||||
const box = boxes[n];
|
||||
// Construct an "extended" box for each, extending out to infinity if
|
||||
// the box is along a boundary.
|
||||
const minX = box.left === leftEdge ? -Infinity : box.left;
|
||||
const minY = box.top === topEdge ? -Infinity : box.top;
|
||||
const maxY = box.bottom === bottomEdge ? Infinity : box.bottom;
|
||||
// The last item in the wrapped list gets a right edge at infinity, even
|
||||
// if it isn't the farthest right. Add this as an "or" condition for extension.
|
||||
const maxX = (n === boxes.length - 1 || box.right === rightEdge) ?
|
||||
Infinity : box.right;
|
||||
|
||||
// Check if the point is in the bounds.
|
||||
if (x > minX && x <= maxX && y > minY && y <= maxY) {
|
||||
index = n;
|
||||
break; // No need to keep looking.
|
||||
}
|
||||
}
|
||||
return index;
|
||||
};
|
||||
|
||||
export {
|
||||
indexForPositionOnList
|
||||
};
|
||||
@@ -0,0 +1,120 @@
|
||||
import bindAll from 'lodash.bindall';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {indexForPositionOnList} from './drag-utils';
|
||||
|
||||
const SortableHOC = function (WrappedComponent) {
|
||||
class SortableWrapper extends React.Component {
|
||||
constructor (props) {
|
||||
super(props);
|
||||
bindAll(this, [
|
||||
'handleAddSortable',
|
||||
'handleRemoveSortable'
|
||||
]);
|
||||
|
||||
this.sortableRefs = [];
|
||||
this.boxes = null;
|
||||
}
|
||||
|
||||
componentWillReceiveProps (newProps) {
|
||||
if (newProps.dragInfo.dragging && !this.props.dragInfo.dragging) {
|
||||
// Drag just started, snapshot the sorted bounding boxes for sortables.
|
||||
this.boxes = this.sortableRefs.map(el => el && el.getBoundingClientRect());
|
||||
this.boxes.sort((a, b) => { // Sort top-to-bottom, left-to-right.
|
||||
if (a.top === b.top) return a.left - b.left;
|
||||
return a.top - b.top;
|
||||
});
|
||||
} else if (!newProps.dragInfo.dragging && this.props.dragInfo.dragging) {
|
||||
this.props.onDrop(Object.assign({},
|
||||
this.props.dragInfo, {newIndex: this.getMouseOverIndex()}));
|
||||
}
|
||||
}
|
||||
|
||||
handleAddSortable (node) {
|
||||
this.sortableRefs.push(node);
|
||||
}
|
||||
|
||||
handleRemoveSortable (node) {
|
||||
const index = this.sortableRefs.indexOf(node);
|
||||
this.sortableRefs = this.sortableRefs.slice(0, index)
|
||||
.concat(this.sortableRefs.slice(index + 1));
|
||||
}
|
||||
|
||||
getOrdering (items, draggingIndex, newIndex) {
|
||||
// An "Ordering" is an array of indices, where the position array value corresponds
|
||||
// to the position of the item in props.items, and the index of the value
|
||||
// is the index at which the item should appear.
|
||||
// That is, if props.items is ['a', 'b', 'c', 'd'], and we want the GUI to display
|
||||
// ['b', 'c', 'a, 'd'], the value of "ordering" would be [1, 2, 0, 3].
|
||||
// This mapping is used because it is easy to translate to flexbox ordering,
|
||||
// the `order` property for item N is ordering.indexOf(N).
|
||||
// If the user-facing order matches props.items, the ordering is just [0, 1, 2, ...]
|
||||
let ordering = Array(this.props.items.length).fill(0)
|
||||
.map((_, i) => i);
|
||||
const isNumber = v => typeof v === 'number' && !isNaN(v);
|
||||
if (isNumber(draggingIndex) && isNumber(newIndex)) {
|
||||
ordering = ordering.slice(0, draggingIndex).concat(ordering.slice(draggingIndex + 1));
|
||||
ordering.splice(newIndex, 0, draggingIndex);
|
||||
}
|
||||
return ordering;
|
||||
}
|
||||
getMouseOverIndex () {
|
||||
// MouseOverIndex is the index that the current drag wants to place the
|
||||
// the dragging object. Obviously only exists if there is a drag (i.e. currentOffset).
|
||||
let mouseOverIndex = null;
|
||||
if (this.props.dragInfo.currentOffset) {
|
||||
mouseOverIndex = indexForPositionOnList(
|
||||
this.props.dragInfo.currentOffset, this.boxes);
|
||||
}
|
||||
return mouseOverIndex;
|
||||
}
|
||||
render () {
|
||||
const {dragInfo: {index: dragIndex, dragType}, items} = this.props;
|
||||
const mouseOverIndex = this.getMouseOverIndex();
|
||||
const ordering = this.getOrdering(items, dragIndex, mouseOverIndex);
|
||||
return (
|
||||
<WrappedComponent
|
||||
draggingIndex={dragIndex}
|
||||
draggingType={dragType}
|
||||
mouseOverIndex={mouseOverIndex}
|
||||
ordering={ordering}
|
||||
onAddSortable={this.handleAddSortable}
|
||||
onRemoveSortable={this.handleRemoveSortable}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SortableWrapper.propTypes = {
|
||||
dragInfo: PropTypes.shape({
|
||||
currentOffset: PropTypes.shape({
|
||||
x: PropTypes.number,
|
||||
y: PropTypes.number
|
||||
}),
|
||||
dragType: PropTypes.string,
|
||||
dragging: PropTypes.bool,
|
||||
index: PropTypes.number
|
||||
}),
|
||||
items: PropTypes.arrayOf(PropTypes.shape({
|
||||
url: PropTypes.string,
|
||||
name: PropTypes.string.isRequired
|
||||
})),
|
||||
onClose: PropTypes.func,
|
||||
onDrop: PropTypes.func
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
dragInfo: state.scratchGui.assetDrag
|
||||
});
|
||||
|
||||
const mapDispatchToProps = () => ({});
|
||||
|
||||
return connect(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps
|
||||
)(SortableWrapper);
|
||||
};
|
||||
|
||||
export default SortableHOC;
|
||||
@@ -0,0 +1,41 @@
|
||||
import {indexForPositionOnList} from '../../../src/lib/drag-utils';
|
||||
|
||||
const box = (top, right, bottom, left) => ({top, right, bottom, left});
|
||||
|
||||
describe('indexForPositionOnList', () => {
|
||||
test('returns null when not given any boxes', () => {
|
||||
expect(indexForPositionOnList({x: 0, y: 0}, [])).toEqual(null);
|
||||
});
|
||||
|
||||
test('wrapped list with incomplete last row', () => {
|
||||
const boxes = [
|
||||
box(0, 100, 100, 0), // index: 0
|
||||
box(0, 200, 100, 100), // index: 1
|
||||
box(0, 300, 100, 200), // index: 2
|
||||
box(100, 100, 200, 0), // index: 3 (second row)
|
||||
box(100, 200, 200, 100) // index: 4 (second row, left incomplete intentionally)
|
||||
];
|
||||
|
||||
// Inside the second box.
|
||||
expect(indexForPositionOnList({x: 150, y: 50}, boxes)).toEqual(1);
|
||||
|
||||
// On the border edge of the first and second box. Given to the first box.
|
||||
expect(indexForPositionOnList({x: 100, y: 50}, boxes)).toEqual(0);
|
||||
|
||||
// Off the top/left edge.
|
||||
expect(indexForPositionOnList({x: -100, y: -100}, boxes)).toEqual(0);
|
||||
|
||||
// Off the left edge, in the second row.
|
||||
expect(indexForPositionOnList({x: -100, y: 175}, boxes)).toEqual(3);
|
||||
|
||||
// Off the right edge, in the first row.
|
||||
expect(indexForPositionOnList({x: 400, y: 75}, boxes)).toEqual(2);
|
||||
|
||||
// Off the top edge, middle of second item.
|
||||
expect(indexForPositionOnList({x: 150, y: -75}, boxes)).toEqual(1);
|
||||
|
||||
// Within the right edge bounds, but on the second (incomplete) row.
|
||||
// This tests that wrapped lists with incomplete final rows work correctly.
|
||||
expect(indexForPositionOnList({x: 375, y: 175}, boxes)).toEqual(4);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user