From 98b0765051414809019d665c5356ec7b326719c1 Mon Sep 17 00:00:00 2001
From: Paul Kaplan <pkaplan@media.mit.edu>
Date: Wed, 30 May 2018 20:17:03 -0400
Subject: [PATCH] Make costume and sound tabs sortable

---
 src/components/asset-panel/selector.css       |  10 +-
 src/components/asset-panel/selector.jsx       |  61 ++++++---
 src/components/asset-panel/sortable-asset.jsx |  45 +++++++
 src/components/drag-layer/drag-layer.css      |  10 +-
 .../sprite-selector-item.css                  |   2 +
 .../sprite-selector/sprite-list.jsx           |  97 ++++++++++++++
 .../sprite-selector/sprite-selector.css       |   5 +
 .../sprite-selector/sprite-selector.jsx       |  42 +++---
 src/components/target-pane/target-pane.jsx    |   3 +
 src/containers/costume-tab.jsx                |  42 ++++--
 src/containers/paint-editor-wrapper.jsx       |  17 ++-
 src/containers/sound-tab.jsx                  |  23 +++-
 src/containers/sprite-selector-item.jsx       |  19 ++-
 src/containers/target-pane.jsx                |  12 +-
 src/lib/drag-constants.js                     |   5 +
 src/lib/drag-utils.js                         |  47 +++++++
 src/lib/sortable-hoc.jsx                      | 120 ++++++++++++++++++
 test/unit/util/drag-utils.test.js             |  41 ++++++
 18 files changed, 524 insertions(+), 77 deletions(-)
 create mode 100644 src/components/asset-panel/sortable-asset.jsx
 create mode 100644 src/components/sprite-selector/sprite-list.jsx
 create mode 100644 src/lib/drag-constants.js
 create mode 100644 src/lib/drag-utils.js
 create mode 100644 src/lib/sortable-hoc.jsx
 create mode 100644 test/unit/util/drag-utils.test.js

diff --git a/src/components/asset-panel/selector.css b/src/components/asset-panel/selector.css
index 98dff10c2..3dc4e803d 100644
--- a/src/components/asset-panel/selector.css
+++ b/src/components/asset-panel/selector.css
@@ -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%);
+}
diff --git a/src/components/asset-panel/selector.jsx b/src/components/asset-panel/selector.jsx
index 6c3f72edb..6899b7abe 100644
--- a/src/components/asset-panel/selector.jsx
+++ b/src/components/asset-panel/selector.jsx
@@ -1,22 +1,32 @@
 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 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 +48,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 +86,20 @@ Selector.propTypes = {
         img: PropTypes.string.isRequired,
         onClick: PropTypes.func
     })),
+    dragType: PropTypes.string,
+    draggingIndex: PropTypes.number,
+    draggingType: PropTypes.string,
     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);
diff --git a/src/components/asset-panel/sortable-asset.jsx b/src/components/asset-panel/sortable-asset.jsx
new file mode 100644
index 000000000..86bae72b5
--- /dev/null
+++ b/src/components/asset-panel/sortable-asset.jsx
@@ -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;
diff --git a/src/components/drag-layer/drag-layer.css b/src/components/drag-layer/drag-layer.css
index 0e89c6b5c..af6ae5dac 100644
--- a/src/components/drag-layer/drag-layer.css
+++ b/src/components/drag-layer/drag-layer.css
@@ -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;
 }
diff --git a/src/components/sprite-selector-item/sprite-selector-item.css b/src/components/sprite-selector-item/sprite-selector-item.css
index ed95f7134..b54e8f720 100644
--- a/src/components/sprite-selector-item/sprite-selector-item.css
+++ b/src/components/sprite-selector-item/sprite-selector-item.css
@@ -19,6 +19,8 @@
     text-align: center;
     cursor: pointer;
     transition: 0.25s ease-out;
+
+    user-select: none;
 }
 
 .sprite-selector-item.is-selected {
diff --git a/src/components/sprite-selector/sprite-list.jsx b/src/components/sprite-selector/sprite-list.jsx
new file mode 100644
index 000000000..54c36764c
--- /dev/null
+++ b/src/components/sprite-selector/sprite-list.jsx
@@ -0,0 +1,97 @@
+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) => (
+                <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={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}
+                        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.string,
+    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);
diff --git a/src/components/sprite-selector/sprite-selector.css b/src/components/sprite-selector/sprite-selector.css
index 43d6681e1..250925cbb 100644
--- a/src/components/sprite-selector/sprite-selector.css
+++ b/src/components/sprite-selector/sprite-selector.css
@@ -103,3 +103,8 @@
     90% { box-shadow: 0 0 10px #7fff1e; }
     100% { box-shadow: none; }
 }
+
+.placeholder > .sprite {
+    background: black;
+    filter: opacity(15%) brightness(20%);
+}
diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx
index 21cef41e9..64a706877 100644
--- a/src/components/sprite-selector/sprite-selector.jsx
+++ b/src/components/sprite-selector/sprite-selector.jsx
@@ -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 styles from './sprite-selector.css';
@@ -49,6 +49,7 @@ const SpriteSelectorComponent = function (props) {
         onChangeSpriteVisibility,
         onChangeSpriteX,
         onChangeSpriteY,
+        onDrop,
         onDeleteSprite,
         onDuplicateSprite,
         onFileUploadClick,
@@ -92,31 +93,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}
@@ -160,6 +147,7 @@ SpriteSelectorComponent.propTypes = {
     onChangeSpriteX: PropTypes.func,
     onChangeSpriteY: PropTypes.func,
     onDeleteSprite: PropTypes.func,
+    onDrop: PropTypes.func,
     onDuplicateSprite: PropTypes.func,
     onFileUploadClick: PropTypes.func,
     onNewSpriteClick: PropTypes.func,
diff --git a/src/components/target-pane/target-pane.jsx b/src/components/target-pane/target-pane.jsx
index fb08f3b41..a258142dc 100644
--- a/src/components/target-pane/target-pane.jsx
+++ b/src/components/target-pane/target-pane.jsx
@@ -27,6 +27,7 @@ const TargetPane = ({
     onChangeSpriteX,
     onChangeSpriteY,
     onDeleteSprite,
+    onDrop,
     onDuplicateSprite,
     onFileUploadClick,
     onNewSpriteClick,
@@ -60,6 +61,7 @@ const TargetPane = ({
             onChangeSpriteX={onChangeSpriteX}
             onChangeSpriteY={onChangeSpriteY}
             onDeleteSprite={onDeleteSprite}
+            onDrop={onDrop}
             onDuplicateSprite={onDuplicateSprite}
             onFileUploadClick={onFileUploadClick}
             onNewSpriteClick={onNewSpriteClick}
@@ -126,6 +128,7 @@ TargetPane.propTypes = {
     onChangeSpriteX: PropTypes.func,
     onChangeSpriteY: PropTypes.func,
     onDeleteSprite: PropTypes.func,
+    onDrop: PropTypes.func,
     onDuplicateSprite: PropTypes.func,
     onFileUploadClick: PropTypes.func,
     onNewSpriteClick: PropTypes.func,
diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx
index 08546f857..965f81abe 100644
--- a/src/containers/costume-tab.jsx
+++ b/src/containers/costume-tab.jsx
@@ -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
 });
 
diff --git a/src/containers/paint-editor-wrapper.jsx b/src/containers/paint-editor-wrapper.jsx
index 9b606f3da..390f6ef59 100644
--- a/src/containers/paint-editor-wrapper.jsx
+++ b/src/containers/paint-editor-wrapper.jsx
@@ -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
     };
 };
diff --git a/src/containers/sound-tab.jsx b/src/containers/sound-tab.jsx
index d532c5ee9..097457d8a 100644
--- a/src/containers/sound-tab.jsx
+++ b/src/containers/sound-tab.jsx
@@ -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}
             >
diff --git a/src/containers/sprite-selector-item.jsx b/src/containers/sprite-selector-item.jsx
index 1a6c77b9d..5efe49428 100644
--- a/src/containers/sprite-selector-item.jsx
+++ b/src/containers/sprite-selector-item.jsx
@@ -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,
diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx
index dd20a4742..994f923bb 100644
--- a/src/containers/target-pane.jsx
+++ b/src/containers/target-pane.jsx
@@ -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}
diff --git a/src/lib/drag-constants.js b/src/lib/drag-constants.js
new file mode 100644
index 000000000..86f064da9
--- /dev/null
+++ b/src/lib/drag-constants.js
@@ -0,0 +1,5 @@
+export default {
+    SOUND: 'SOUND',
+    COSTUME: 'COSTUME',
+    SPRITE: 'SPRITE'
+};
diff --git a/src/lib/drag-utils.js b/src/lib/drag-utils.js
new file mode 100644
index 000000000..e3f5c1bd0
--- /dev/null
+++ b/src/lib/drag-utils.js
@@ -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
+};
diff --git a/src/lib/sortable-hoc.jsx b/src/lib/sortable-hoc.jsx
new file mode 100644
index 000000000..665c5bc43
--- /dev/null
+++ b/src/lib/sortable-hoc.jsx
@@ -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;
diff --git a/test/unit/util/drag-utils.test.js b/test/unit/util/drag-utils.test.js
new file mode 100644
index 000000000..73029564e
--- /dev/null
+++ b/test/unit/util/drag-utils.test.js
@@ -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);
+    });
+});
-- 
GitLab