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