Merge pull request #2323 from paulkaplan/reorderable-sprites

Make costumes, sounds and sprites sortable
This commit is contained in:
Paul Kaplan authored and GitHub committed 2018-06-14 13:22:17 -04:00
commit a4603bce1d
18 files changed
+536 -77

No files matched your search

+9 -1
View File
@@ -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%);
}
+45 -17
View File
@@ -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;
+9 -1
View File
@@ -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,
+28 -14
View File
@@ -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
});
+8 -9
View File
@@ -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
};
};
+19 -4
View File
@@ -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}
>
+16 -3
View File
@@ -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,
+11 -1
View File
@@ -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}
+5
View File
@@ -0,0 +1,5 @@
export default {
SOUND: 'SOUND',
COSTUME: 'COSTUME',
SPRITE: 'SPRITE'
};
+47
View File
@@ -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
};
+120
View File
@@ -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;
+41
View File
@@ -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);
});
});