diff --git a/src/components/drag-layer/drag-layer.css b/src/components/drag-layer/drag-layer.css new file mode 100644 index 0000000000000000000000000000000000000000..711b5eea813d6a2d80b97d75c98c40fbb68e8457 --- /dev/null +++ b/src/components/drag-layer/drag-layer.css @@ -0,0 +1,28 @@ +@import "../../css/units.css"; +@import "../../css/colors.css"; + +.drag-layer { + position: fixed; + pointer-events: none; + z-index: 1000; /* Above everything */ + left: 0; + top: 0; + width: 100%; + height: 100% +} + +.image-wrapper { + /* Absolute allows wrapper to snuggly fit image */ + position: absolute; +} + +.image { + max-width: 80px; + + /* Center the dragging image on the given position */ + margin-left: -50%; + margin-top: -50%; + + /* Use the same drop shadow as stage dragging */ + filter: drop-shadow(5px 5px 5px $ui-black-transparent); +} diff --git a/src/components/drag-layer/drag-layer.jsx b/src/components/drag-layer/drag-layer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..7df2bd7cf828f822b7e9b22fecc8378fc187143b --- /dev/null +++ b/src/components/drag-layer/drag-layer.jsx @@ -0,0 +1,31 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styles from './drag-layer.css'; + +/* eslint no-confusing-arrow: ["error", {"allowParens": true}] */ +const DragLayer = ({dragging, img, currentOffset}) => (dragging ? ( + <div className={styles.dragLayer}> + <div + className={styles.imageWrapper} + style={{ + transform: `translate(${currentOffset.x}px, ${currentOffset.y}px)` + }} + > + <img + className={styles.image} + src={img} + /> + </div> + </div> +) : null); + +DragLayer.propTypes = { + currentOffset: PropTypes.shape({ + x: PropTypes.number.isRequired, + y: PropTypes.number.isRequired + }), + dragging: PropTypes.bool.isRequired, + img: PropTypes.string +}; + +export default DragLayer; diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index ad02b2feda7d185e3cd468347363b93a77e58ac7..98a4e83d12856d1d1ffc7568b6287a84f030c925 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -21,6 +21,7 @@ import ImportModal from '../../containers/import-modal.jsx'; import WebGlModal from '../../containers/webgl-modal.jsx'; import TipsLibrary from '../../containers/tips-library.jsx'; import Cards from '../../containers/cards.jsx'; +import DragLayer from '../../containers/drag-layer.jsx'; import styles from './gui.css'; import addExtensionIcon from './icon--extensions.svg'; @@ -215,6 +216,7 @@ const GUIComponent = props => { </Box> </Box> </Box> + <DragLayer /> </Box> ); }; diff --git a/src/components/sprite-selector-item/sprite-selector-item.jsx b/src/components/sprite-selector-item/sprite-selector-item.jsx index e947b380e25e3470bbb2a00c99d562033fe35995..62364ec53cab6605f552eeb06112ceb3fa58bb1d 100644 --- a/src/components/sprite-selector-item/sprite-selector-item.jsx +++ b/src/components/sprite-selector-item/sprite-selector-item.jsx @@ -20,8 +20,11 @@ const SpriteSelectorItem = props => ( }), onClick: props.onClick, onMouseEnter: props.onMouseEnter, - onMouseLeave: props.onMouseLeave + onMouseLeave: props.onMouseLeave, + onMouseDown: props.onMouseDown, + onTouchStart: props.onMouseDown }} + disable={props.dragging} id={`${props.name}-${contextMenuId}`} > {(props.selected && props.onDeleteButtonClick) ? ( @@ -77,11 +80,13 @@ SpriteSelectorItem.propTypes = { className: PropTypes.string, costumeURL: PropTypes.string, details: PropTypes.string, + dragging: PropTypes.bool, name: PropTypes.string.isRequired, number: PropTypes.number, onClick: PropTypes.func, onDeleteButtonClick: PropTypes.func, onDuplicateButtonClick: PropTypes.func, + onMouseDown: PropTypes.func, onMouseEnter: PropTypes.func, onMouseLeave: PropTypes.func, selected: PropTypes.bool.isRequired diff --git a/src/containers/drag-layer.jsx b/src/containers/drag-layer.jsx new file mode 100644 index 0000000000000000000000000000000000000000..f52008f35f0cf144ca1c9bc1c404b062edf06674 --- /dev/null +++ b/src/containers/drag-layer.jsx @@ -0,0 +1,10 @@ +import {connect} from 'react-redux'; +import DragLayer from '../components/drag-layer/drag-layer.jsx'; + +const mapStateToProps = state => ({ + dragging: state.scratchGui.assetDrag.dragging, + currentOffset: state.scratchGui.assetDrag.currentOffset, + img: state.scratchGui.assetDrag.img +}); + +export default connect(mapStateToProps)(DragLayer); diff --git a/src/containers/sprite-selector-item.jsx b/src/containers/sprite-selector-item.jsx index 7d3fc2f21c45677102ef9feb3b4322c48b45dfe6..1a6c77b9d970831cda6f8b5c307de99fbf21d7f3 100644 --- a/src/containers/sprite-selector-item.jsx +++ b/src/containers/sprite-selector-item.jsx @@ -4,9 +4,13 @@ import React from 'react'; import {connect} from 'react-redux'; import {setHoveredSprite} from '../reducers/hovered-target'; +import {updateAssetDrag} from '../reducers/asset-drag'; +import {getEventXY} from '../lib/touch-utils'; import SpriteSelectorItemComponent from '../components/sprite-selector-item/sprite-selector-item.jsx'; +const dragThreshold = 3; // Same as the block drag threshold + class SpriteSelectorItem extends React.Component { constructor (props) { super(props); @@ -15,9 +19,44 @@ class SpriteSelectorItem extends React.Component { 'handleDelete', 'handleDuplicate', 'handleMouseEnter', - 'handleMouseLeave' + 'handleMouseLeave', + 'handleMouseDown', + 'handleMouseMove', + 'handleMouseUp' ]); } + handleMouseUp () { + this.initialOffset = null; + window.removeEventListener('mouseup', this.handleMouseUp); + window.removeEventListener('mousemove', this.handleMouseMove); + window.removeEventListener('touchend', this.handleMouseUp); + window.removeEventListener('touchmove', this.handleMouseMove); + this.props.onDrag({ + img: null, + currentOffset: null, + dragging: false + }); + } + handleMouseMove (e) { + const currentOffset = getEventXY(e); + const dx = currentOffset.x - this.initialOffset.x; + const dy = currentOffset.y - this.initialOffset.y; + if (Math.sqrt((dx * dx) + (dy * dy)) > dragThreshold) { + this.props.onDrag({ + img: this.props.costumeURL, + currentOffset: currentOffset, + dragging: true + }); + } + e.preventDefault(); + } + handleMouseDown (e) { + this.initialOffset = getEventXY(e); + window.addEventListener('mouseup', this.handleMouseUp); + window.addEventListener('mousemove', this.handleMouseMove); + window.addEventListener('touchend', this.handleMouseUp); + window.addEventListener('touchmove', this.handleMouseMove); + } handleClick (e) { e.preventDefault(); this.props.onClick(this.props.id); @@ -57,6 +96,7 @@ class SpriteSelectorItem extends React.Component { onClick={this.handleClick} onDeleteButtonClick={onDeleteButtonClick ? this.handleDelete : null} onDuplicateButtonClick={onDuplicateButtonClick ? this.handleDuplicate : null} + onMouseDown={this.handleMouseDown} onMouseEnter={this.handleMouseEnter} onMouseLeave={this.handleMouseLeave} {...props} @@ -73,6 +113,7 @@ SpriteSelectorItem.propTypes = { name: PropTypes.string, onClick: PropTypes.func, onDeleteButtonClick: PropTypes.func, + onDrag: PropTypes.func.isRequired, onDuplicateButtonClick: PropTypes.func, receivedBlocks: PropTypes.bool.isRequired, selected: PropTypes.bool @@ -80,13 +121,15 @@ SpriteSelectorItem.propTypes = { const mapStateToProps = (state, {assetId, costumeURL, id}) => ({ costumeURL: costumeURL || (assetId && state.scratchGui.vm.runtime.storage.get(assetId).encodeDataURI()), + dragging: state.scratchGui.assetDrag.dragging, receivedBlocks: state.scratchGui.hoveredTarget.receivedBlocks && state.scratchGui.hoveredTarget.sprite === id }); const mapDispatchToProps = dispatch => ({ dispatchSetHoveredSprite: spriteId => { dispatch(setHoveredSprite(spriteId)); - } + }, + onDrag: data => dispatch(updateAssetDrag(data)) }); export default connect( diff --git a/src/reducers/asset-drag.js b/src/reducers/asset-drag.js new file mode 100644 index 0000000000000000000000000000000000000000..b8081581280e0ac0ff07c511d01933f47f23d054 --- /dev/null +++ b/src/reducers/asset-drag.js @@ -0,0 +1,31 @@ +const DRAG_UPDATE = 'scratch-gui/asset-drag/DRAG_UPDATE'; + +const initialState = { + dragging: false, + currentOffset: null, + img: null +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + + switch (action.type) { + case DRAG_UPDATE: + return Object.assign({}, state, action.state); + default: + return state; + } +}; + +const updateAssetDrag = function (state) { + return { + type: DRAG_UPDATE, + state: state + }; +}; + +export { + reducer as default, + initialState as assetDragInitialState, + updateAssetDrag +}; diff --git a/src/reducers/gui.js b/src/reducers/gui.js index e28b757163b50b0c10c8265222e6bc4f877a14d8..829f0b0446171e56ca2c3157ea316c5611727d2a 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -1,4 +1,5 @@ import {applyMiddleware, compose, combineReducers} from 'redux'; +import assetDragReducer, {assetDragInitialState} from './asset-drag'; import cardsReducer, {cardsInitialState} from './cards'; import colorPickerReducer, {colorPickerInitialState} from './color-picker'; import customProceduresReducer, {customProceduresInitialState} from './custom-procedures'; @@ -19,6 +20,7 @@ import throttle from 'redux-throttle'; const guiMiddleware = compose(applyMiddleware(throttle(300, {leading: true, trailing: true}))); const guiInitialState = { + assetDrag: assetDragInitialState, blockDrag: blockDragInitialState, cards: cardsInitialState, colorPicker: colorPickerInitialState, @@ -57,6 +59,7 @@ const initFullScreen = function (currentState) { ); }; const guiReducer = combineReducers({ + assetDrag: assetDragReducer, blockDrag: blockDragReducer, cards: cardsReducer, colorPicker: colorPickerReducer, diff --git a/test/unit/containers/sprite-selector-item.test.jsx b/test/unit/containers/sprite-selector-item.test.jsx index 23bda4e143de8f2ed55a7aac834cf182ffe5bdbd..85032603d4b3a1712db3fb431a96d109dc40d7d7 100644 --- a/test/unit/containers/sprite-selector-item.test.jsx +++ b/test/unit/containers/sprite-selector-item.test.jsx @@ -37,8 +37,9 @@ describe('SpriteSelectorItem Container', () => { beforeEach(() => { store = mockStore({scratchGui: { - hoveredTarget: {receivedBlocks: false, sprite: null}} - }); + hoveredTarget: {receivedBlocks: false, sprite: null}, + assetDrag: {dragging: false} + }}); className = 'ponies'; costumeURL = 'https://scratch.mit.edu/foo/bar/pony'; id = 1337;