diff --git a/package.json b/package.json index e44c89a024c70922b937446d231ca6829ae5e337..685aae91354c8981a407daeea797dbdee3f0522a 100644 --- a/package.json +++ b/package.json @@ -94,7 +94,7 @@ "scratch-paint": "0.2.0-prerelease.20180222192821", "scratch-render": "0.1.0-prerelease.1516837442", "scratch-storage": "0.4.0", - "scratch-vm": "0.1.0-prerelease.1519653784-prerelease.1519653801", + "scratch-vm": "0.1.0-prerelease.1519681201-prerelease.1519681262", "selenium-webdriver": "3.6.0", "startaudiocontext": "1.2.1", "style-loader": "^0.20.0", diff --git a/src/components/sprite-selector-item/sprite-selector-item.jsx b/src/components/sprite-selector-item/sprite-selector-item.jsx index aa292cfaa8e1dc2601c87f3544084160124b8770..abb47bb5478bcb572726ac2a838f88efe8476e06 100644 --- a/src/components/sprite-selector-item/sprite-selector-item.jsx +++ b/src/components/sprite-selector-item/sprite-selector-item.jsx @@ -18,7 +18,9 @@ const SpriteSelectorItem = props => ( className: classNames(props.className, styles.spriteSelectorItem, { [styles.isSelected]: props.selected }), - onClick: props.onClick + onClick: props.onClick, + onMouseEnter: props.onMouseEnter, + onMouseLeave: props.onMouseLeave }} id={`${props.name}-${contextMenuId}`} > @@ -70,6 +72,8 @@ SpriteSelectorItem.propTypes = { onClick: PropTypes.func, onDeleteButtonClick: PropTypes.func, onDuplicateButtonClick: PropTypes.func, + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, selected: PropTypes.bool.isRequired }; diff --git a/src/components/sprite-selector/sprite-selector.css b/src/components/sprite-selector/sprite-selector.css index 4cb03ecd581fa759aa51b6563e531a712ab4f438..ec5cfee2b786dfebbfb66e94f80ace2651f7006d 100644 --- a/src/components/sprite-selector/sprite-selector.css +++ b/src/components/sprite-selector/sprite-selector.css @@ -66,3 +66,81 @@ right: 1rem; z-index: 1; /* TODO overlaps the stage, this doesn't work, fix! */ } + +.raised { + background-color: #cce1ff; + border-color: #8cbcff; + transition: all 0.25s ease; +} + +.raised:hover { + background-color: #8cbcff; + transform: scale(1.05); +} + +.raised:hover { + -webkit-animation-name: wiggle; + -ms-animation-name: wiggle; + -ms-animation-duration: 500ms; + -webkit-animation-duration: 500ms; + -webkit-animation-iteration-count: 1; + -ms-animation-iteration-count: 1; + -webkit-animation-timing-function: ease-in-out; + -ms-animation-timing-function: ease-in-out; + background-color: #8cbcff; +} + +@-webkit-keyframes wiggle { + 0% {-webkit-transform: rotate(3deg) scale(1.05);} + 25% {-webkit-transform: rotate(-3deg) scale(1.05);} + 50% {-webkit-transform: rotate(5deg) scale(1.05);} + 75% {-webkit-transform: rotate(-2deg) scale(1.05);} + 100% {-webkit-transform: rotate(0deg) scale(1.05);} +} + +@-ms-keyframes wiggle { + 0% {-ms-transform: rotate(3deg) scale(1.05);} + 25% {-ms-transform: rotate(-3deg) scale(1.05);} + 50% {-ms-transform: rotate(5deg) scale(1.05);} + 75% {-ms-transform: rotate(-2deg) scale(1.05);} + 100% {-ms-transform: rotate(0deg) scale(1.05);} +} + +@keyframes wiggle { + 0% {transform: rotate(3deg) scale(1.05);} + 25% {transform: rotate(-3deg) scale(1.05);} + 50% {transform: rotate(5deg) scale(1.05);} + 75% {transform: rotate(-2deg) scale(1.05);} + 100% {transform: rotate(0deg) scale(1.05);} +} + +.receivedBlocks { + -webkit-animation: glowing 250ms; + -moz-animation: glowing 250ms; + -o-animation: glowing 250ms; + animation: glowing 250ms; +} + +@-webkit-keyframes glowing { + 10% { -webkit-box-shadow: 0 0 10px #7fff1e; } + 90% { -webkit-box-shadow: 0 0 10px #7fff1e; } + 100% { -webkit-box-shadow: none; } +} + +@-moz-keyframes glowing { + 10% { -moz-box-shadow: 0 0 10px #7fff1e; } + 90% { -moz-box-shadow: 0 0 10px #7fff1e; } + 100% { -moz-box-shadow: none; } +} + +@-o-keyframes glowing { + 10% { box-shadow: 0 0 10px #7fff1e; } + 90% { box-shadow: 0 0 10px #7fff1e; } + 100% { box-shadow: none; } +} + +@keyframes glowing { + 10% { box-shadow: 0 0 10px #7fff1e; } + 90% { box-shadow: 0 0 10px #7fff1e; } + 100% { box-shadow: none; } +} diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx index 7590f286e5e49507a12a189830950cce07b2a6c5..24e5315c6527f88e2208909232a17c431704b0de 100644 --- a/src/components/sprite-selector/sprite-selector.jsx +++ b/src/components/sprite-selector/sprite-selector.jsx @@ -1,5 +1,6 @@ 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'; @@ -45,6 +46,8 @@ const messages = defineMessages({ const SpriteSelectorComponent = function (props) { const { + editingTarget, + hoveredTarget, intl, onChangeSpriteDirection, onChangeSpriteName, @@ -58,6 +61,7 @@ const SpriteSelectorComponent = function (props) { onSurpriseSpriteClick, onPaintSpriteClick, onSelectSprite, + raised, selectedId, sprites, ...componentProps @@ -99,7 +103,12 @@ const SpriteSelectorComponent = function (props) { .map(sprite => ( <SpriteSelectorItem assetId={sprite.costume && sprite.costume.assetId} - className={styles.sprite} + 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} @@ -140,6 +149,11 @@ const SpriteSelectorComponent = function (props) { }; SpriteSelectorComponent.propTypes = { + editingTarget: PropTypes.string, + hoveredTarget: PropTypes.shape({ + hoveredSprite: PropTypes.string, + receivedBlocks: PropTypes.bool + }), intl: intlShape.isRequired, onChangeSpriteDirection: PropTypes.func, onChangeSpriteName: PropTypes.func, @@ -153,6 +167,7 @@ SpriteSelectorComponent.propTypes = { onPaintSpriteClick: PropTypes.func, onSelectSprite: PropTypes.func, onSurpriseSpriteClick: PropTypes.func, + raised: PropTypes.bool, selectedId: PropTypes.string, sprites: PropTypes.shape({ id: PropTypes.shape({ diff --git a/src/components/target-pane/target-pane.jsx b/src/components/target-pane/target-pane.jsx index 7369f07fc1e2dfe63be27b5af52c5b0375eab018..1044073a1cc18b438633e487019d03f2d10065e4 100644 --- a/src/components/target-pane/target-pane.jsx +++ b/src/components/target-pane/target-pane.jsx @@ -19,6 +19,7 @@ import styles from './target-pane.css'; const TargetPane = ({ backdropLibraryVisible, editingTarget, + hoveredTarget, spriteLibraryVisible, onChangeSpriteDirection, onChangeSpriteName, @@ -34,6 +35,7 @@ const TargetPane = ({ onRequestCloseSpriteLibrary, onRequestCloseBackdropLibrary, onSelectSprite, + raiseSprites, stage, sprites, vm, @@ -45,6 +47,9 @@ const TargetPane = ({ > <SpriteSelectorComponent + editingTarget={editingTarget} + hoveredTarget={hoveredTarget} + raised={raiseSprites} selectedId={editingTarget} sprites={sprites} onChangeSpriteDirection={onChangeSpriteDirection} @@ -111,6 +116,10 @@ TargetPane.propTypes = { backdropLibraryVisible: PropTypes.bool, editingTarget: PropTypes.string, extensionLibraryVisible: PropTypes.bool, + hoveredTarget: PropTypes.shape({ + hoveredSprite: PropTypes.string, + receivedBlocks: PropTypes.bool + }), onChangeSpriteDirection: PropTypes.func, onChangeSpriteName: PropTypes.func, onChangeSpriteSize: PropTypes.func, @@ -126,6 +135,7 @@ TargetPane.propTypes = { onRequestCloseSpriteLibrary: PropTypes.func, onSelectSprite: PropTypes.func, onSurpriseSpriteClick: PropTypes.func, + raiseSprites: PropTypes.bool, spriteLibraryVisible: PropTypes.bool, sprites: PropTypes.objectOf(spriteShape), stage: spriteShape, diff --git a/src/containers/sprite-selector-item.jsx b/src/containers/sprite-selector-item.jsx index 240a1bcb4742af2542a89d22123aea162354cbc2..edb124b2c6086874d40cbc6734c0f892efb3c6a4 100644 --- a/src/containers/sprite-selector-item.jsx +++ b/src/containers/sprite-selector-item.jsx @@ -1,9 +1,10 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; - import {connect} from 'react-redux'; +import {setHoveredSprite} from '../reducers/hovered-target'; + import SpriteSelectorItemComponent from '../components/sprite-selector-item/sprite-selector-item.jsx'; class SpriteSelectorItem extends React.Component { @@ -12,7 +13,9 @@ class SpriteSelectorItem extends React.Component { bindAll(this, [ 'handleClick', 'handleDelete', - 'handleDuplicate' + 'handleDuplicate', + 'handleMouseEnter', + 'handleMouseLeave' ]); } handleClick (e) { @@ -31,6 +34,12 @@ class SpriteSelectorItem extends React.Component { e.stopPropagation(); // To prevent from bubbling back to handleClick this.props.onDuplicateButtonClick(this.props.id); } + handleMouseLeave () { + this.props.dispatchSetHoveredSprite(null); + } + handleMouseEnter () { + this.props.dispatchSetHoveredSprite(this.props.id); + } render () { const { /* eslint-disable no-unused-vars */ @@ -39,6 +48,7 @@ class SpriteSelectorItem extends React.Component { onClick, onDeleteButtonClick, onDuplicateButtonClick, + receivedBlocks, /* eslint-enable no-unused-vars */ ...props } = this.props; @@ -47,6 +57,8 @@ class SpriteSelectorItem extends React.Component { onClick={this.handleClick} onDeleteButtonClick={onDeleteButtonClick ? this.handleDelete : null} onDuplicateButtonClick={onDuplicateButtonClick ? this.handleDuplicate : null} + onMouseEnter={this.handleMouseEnter} + onMouseLeave={this.handleMouseLeave} {...props} /> ); @@ -56,18 +68,28 @@ class SpriteSelectorItem extends React.Component { SpriteSelectorItem.propTypes = { assetId: PropTypes.string, costumeURL: PropTypes.string, + dispatchSetHoveredSprite: PropTypes.func.isRequired, id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), name: PropTypes.string, onClick: PropTypes.func, onDeleteButtonClick: PropTypes.func, onDuplicateButtonClick: PropTypes.func, + receivedBlocks: PropTypes.bool.isRequired, selected: PropTypes.bool }; -const mapStateToProps = (state, {assetId, costumeURL}) => ({ - costumeURL: costumeURL || (assetId && state.vm.runtime.storage.get(assetId).encodeDataURI()) +const mapStateToProps = (state, {assetId, costumeURL, id}) => ({ + costumeURL: costumeURL || (assetId && state.vm.runtime.storage.get(assetId).encodeDataURI()), + receivedBlocks: state.hoveredTarget.receivedBlocks && + state.hoveredTarget.sprite === id +}); +const mapDispatchToProps = dispatch => ({ + dispatchSetHoveredSprite: spriteId => { + dispatch(setHoveredSprite(spriteId)); + } }); export default connect( - mapStateToProps + mapStateToProps, + mapDispatchToProps )(SpriteSelectorItem); diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx index 8928d9fe85e5ea5612d2c3b01a9f4e493bc4d26d..de6d6e9b1ced54abfc01f670d04e271817936b54 100644 --- a/src/containers/target-pane.jsx +++ b/src/containers/target-pane.jsx @@ -106,6 +106,7 @@ TargetPane.propTypes = { const mapStateToProps = state => ({ editingTarget: state.targets.editingTarget, + hoveredTarget: state.hoveredTarget, sprites: Object.keys(state.targets.sprites).reduce((sprites, k) => { let {direction, size, x, y, ...sprite} = state.targets.sprites[k]; if (typeof direction !== 'undefined') direction = Math.round(direction); @@ -116,6 +117,7 @@ const mapStateToProps = state => ({ return sprites; }, {}), stage: state.targets.stage, + raiseSprites: state.blockDrag, spriteLibraryVisible: state.modals.spriteLibrary, backdropLibraryVisible: state.modals.backdropLibrary }); diff --git a/src/lib/vm-listener-hoc.jsx b/src/lib/vm-listener-hoc.jsx index 081817c780e5518dbc0cfe4d3e88f06423809d88..3ae81fdbbffcf91af1a5262abab9c69e97e710a4 100644 --- a/src/lib/vm-listener-hoc.jsx +++ b/src/lib/vm-listener-hoc.jsx @@ -6,7 +6,9 @@ import VM from 'scratch-vm'; import {connect} from 'react-redux'; import {updateEditingTarget, updateTargets} from '../reducers/targets'; +import {updateBlockDrag} from '../reducers/block-drag'; import {updateMonitors} from '../reducers/monitors'; +import {setReceivedBlocks} from '../reducers/hovered-target'; /* * Higher Order Component to manage events emitted by the VM @@ -18,6 +20,7 @@ const vmListenerHOC = function (WrappedComponent) { constructor (props) { super(props); bindAll(this, [ + 'handleBlockDragEnd', 'handleKeyDown', 'handleKeyUp' ]); @@ -29,6 +32,8 @@ const vmListenerHOC = function (WrappedComponent) { // we need to start listening before mounting the wrapped component. this.props.vm.on('targetsUpdate', this.props.onTargetsUpdate); this.props.vm.on('MONITORS_UPDATE', this.props.onMonitorsUpdate); + this.props.vm.on('BLOCK_DRAG_UPDATE', this.props.onBlockDragUpdate); + this.props.vm.on('BLOCK_DRAG_END', this.handleBlockDragEnd); } componentDidMount () { @@ -37,12 +42,21 @@ const vmListenerHOC = function (WrappedComponent) { document.addEventListener('keyup', this.handleKeyUp); } } + shouldComponentUpdate () { + return false; + } componentWillUnmount () { if (this.props.attachKeyboardEvents) { document.removeEventListener('keydown', this.handleKeyDown); document.removeEventListener('keyup', this.handleKeyUp); } } + handleBlockDragEnd (blocks) { + if (this.props.hoveredSprite && this.props.hoveredSprite !== this.props.editingTarget) { + this.props.vm.shareBlocksToTarget(blocks, this.props.hoveredSprite); + this.props.onReceivedBlocks(true); + } + } handleKeyDown (e) { // Don't capture keys intended for Blockly inputs. if (e.target !== document && e.target !== document.body) return; @@ -74,9 +88,13 @@ const vmListenerHOC = function (WrappedComponent) { const { /* eslint-disable no-unused-vars */ attachKeyboardEvents, + editingTarget, + hoveredSprite, + onBlockDragUpdate, onKeyDown, onKeyUp, onMonitorsUpdate, + onReceivedBlocks, onTargetsUpdate, /* eslint-enable no-unused-vars */ ...props @@ -86,17 +104,23 @@ const vmListenerHOC = function (WrappedComponent) { } VMListener.propTypes = { attachKeyboardEvents: PropTypes.bool, + editingTarget: PropTypes.string, + hoveredSprite: PropTypes.string, + onBlockDragUpdate: PropTypes.func.isRequired, onKeyDown: PropTypes.func, onKeyUp: PropTypes.func, - onMonitorsUpdate: PropTypes.func, - onTargetsUpdate: PropTypes.func, + onMonitorsUpdate: PropTypes.func.isRequired, + onReceivedBlocks: PropTypes.func.isRequired, + onTargetsUpdate: PropTypes.func.isRequired, vm: PropTypes.instanceOf(VM).isRequired }; VMListener.defaultProps = { attachKeyboardEvents: true }; const mapStateToProps = state => ({ - vm: state.vm + vm: state.vm, + hoveredSprite: state.hoveredTarget.sprite, + editingTarget: state.targets.editingTarget }); const mapDispatchToProps = dispatch => ({ onTargetsUpdate: data => { @@ -105,6 +129,12 @@ const vmListenerHOC = function (WrappedComponent) { }, onMonitorsUpdate: monitorList => { dispatch(updateMonitors(monitorList)); + }, + onBlockDragUpdate: areBlocksOverGui => { + dispatch(updateBlockDrag(areBlocksOverGui)); + }, + onReceivedBlocks: receivedBlocks => { + dispatch(setReceivedBlocks(receivedBlocks)); } }); return connect( diff --git a/src/reducers/block-drag.js b/src/reducers/block-drag.js new file mode 100644 index 0000000000000000000000000000000000000000..5a261ceaa93459575a41046dc87b664c3af7c6b1 --- /dev/null +++ b/src/reducers/block-drag.js @@ -0,0 +1,28 @@ +const BLOCK_DRAG_UPDATE = 'scratch-gui/block-drag/BLOCK_DRAG_UPDATE'; + +const initialState = false; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case BLOCK_DRAG_UPDATE: + return action.areBlocksOverGui; + default: + return state; + } +}; + +const updateBlockDrag = function (areBlocksOverGui) { + return { + type: BLOCK_DRAG_UPDATE, + areBlocksOverGui: areBlocksOverGui, + meta: { + throttle: 30 + } + }; +}; + +export { + reducer as default, + updateBlockDrag +}; diff --git a/src/reducers/gui.js b/src/reducers/gui.js index d199668371651d434d3736956055ff43ff4342eb..d89a195c9dc2d485082337e5a0961f18e2e63ddd 100644 --- a/src/reducers/gui.js +++ b/src/reducers/gui.js @@ -1,6 +1,9 @@ import {combineReducers} from 'redux'; import colorPickerReducer from './color-picker'; import customProceduresReducer from './custom-procedures'; +import blockDragReducer from './block-drag'; +import editorTabReducer from './editor-tab'; +import hoveredTargetReducer from './hovered-target'; import intlReducer from './intl'; import modalReducer from './modals'; import monitorReducer from './monitors'; @@ -9,13 +12,14 @@ import targetReducer from './targets'; import toolboxReducer from './toolbox'; import vmReducer from './vm'; import stageSizeReducer from './stage-size'; -import editorTabReducer from './editor-tab'; import {ScratchPaintReducer} from 'scratch-paint'; export default combineReducers({ + blockDrag: blockDragReducer, colorPicker: colorPickerReducer, customProcedures: customProceduresReducer, editorTab: editorTabReducer, + hoveredTarget: hoveredTargetReducer, intl: intlReducer, stageSize: stageSizeReducer, modals: modalReducer, diff --git a/src/reducers/hovered-target.js b/src/reducers/hovered-target.js new file mode 100644 index 0000000000000000000000000000000000000000..43bf87ac0bb6ce81e4471955b31dec5f90eef4a5 --- /dev/null +++ b/src/reducers/hovered-target.js @@ -0,0 +1,48 @@ +const SET_HOVERED_SPRITE = 'scratch-gui/hovered-target/SET_HOVERED_SPRITE'; +const SET_RECEIVED_BLOCKS = 'scratch-gui/hovered-target/SET_RECEIVED_BLOCKS'; + +const initialState = { + sprite: null, + receivedBlocks: false +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case SET_HOVERED_SPRITE: + return { + sprite: action.spriteId, + receivedBlocks: false + }; + case SET_RECEIVED_BLOCKS: + return { + sprite: state.sprite, + receivedBlocks: action.receivedBlocks + }; + default: + return state; + } +}; + +const setHoveredSprite = function (spriteId) { + return { + type: SET_HOVERED_SPRITE, + spriteId: spriteId, + meta: { + throttle: 30 + } + }; +}; + +const setReceivedBlocks = function (receivedBlocks) { + return { + type: SET_RECEIVED_BLOCKS, + receivedBlocks: receivedBlocks + }; +}; + +export { + reducer as default, + setHoveredSprite, + setReceivedBlocks +}; diff --git a/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap b/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap index 19abc2a16d378f0cc2a9dbe1dbf93fe4748b2c45..4ecf19565c1de4f56bef8dfb883fda7e6d199f56 100644 --- a/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap +++ b/test/unit/components/__snapshots__/sprite-selector-item.test.jsx.snap @@ -6,6 +6,8 @@ exports[`SpriteSelectorItemComponent matches snapshot when selected 1`] = ` onClick={[Function]} onContextMenu={[Function]} onMouseDown={[Function]} + onMouseEnter={undefined} + onMouseLeave={undefined} onMouseOut={[Function]} onMouseUp={[Function]} onTouchEnd={[Function]} diff --git a/test/unit/containers/sprite-selector-item.test.jsx b/test/unit/containers/sprite-selector-item.test.jsx index 2a371a1a0dfe763c32737ee832a9a3009e57b0e7..a423fe81635a3685b6521a73f12824f3a5b380e0 100644 --- a/test/unit/containers/sprite-selector-item.test.jsx +++ b/test/unit/containers/sprite-selector-item.test.jsx @@ -12,6 +12,7 @@ describe('SpriteSelectorItem Container', () => { let costumeURL; let name; let onClick; + let dispatchSetHoveredSprite; let onDeleteButtonClick; let selected; let id; @@ -23,6 +24,7 @@ describe('SpriteSelectorItem Container', () => { <SpriteSelectorItem className={className} costumeURL={costumeURL} + dispatchSetHoveredSprite={dispatchSetHoveredSprite} id={id} name={name} selected={selected} @@ -34,13 +36,14 @@ describe('SpriteSelectorItem Container', () => { }; beforeEach(() => { - store = mockStore(); + store = mockStore({hoveredTarget: {receivedBlocks: false, sprite: null}}); className = 'ponies'; costumeURL = 'https://scratch.mit.edu/foo/bar/pony'; id = 1337; name = 'Pony sprite'; onClick = jest.fn(); onDeleteButtonClick = jest.fn(); + dispatchSetHoveredSprite = jest.fn(); selected = true; // Mock window.confirm() which is called when the close button is clicked. global.confirm = jest.fn(() => true);