diff --git a/src/components/direction-picker/dial.css b/src/components/direction-picker/dial.css new file mode 100644 index 0000000000000000000000000000000000000000..ce9b7daea87f1f91f59437877c0504255b758666 --- /dev/null +++ b/src/components/direction-picker/dial.css @@ -0,0 +1,41 @@ +@import "../../css/colors.css"; + +.container { + padding: 1rem; + display: flex; + justify-content: center; + align-items: center; + user-select: none; +} + +.dial-container { + position: relative; +} + +.dial-face, .dial-handle, .gauge { + position: absolute; + top: 0; + left: 0; + overflow: visible; +} + +.dial-face { + width: 100%; +} + +$dial-size: 40px; + +.dial-handle { + cursor: pointer; + width: $dial-size; + height: $dial-size; + /* Use margin to make positioning via top/left easier */ + margin-left: calc($dial-size / -2); + margin-top: calc($dial-size / -2); +} + +.gauge-path { + fill: $motion-transparent; + stroke: $motion-primary; + stroke-width: 1px; +} diff --git a/src/components/direction-picker/dial.jsx b/src/components/direction-picker/dial.jsx new file mode 100644 index 0000000000000000000000000000000000000000..df97fda7fa4cc03cdf61f2b4fc52f6dfcd05c814 --- /dev/null +++ b/src/components/direction-picker/dial.jsx @@ -0,0 +1,156 @@ +import PropTypes from 'prop-types'; +import bindAll from 'lodash.bindall'; +import React from 'react'; +import {getEventXY} from '../../lib/touch-utils'; + +import styles from './dial.css'; + +import dialFace from './icon--dial.svg'; +import dialHandle from './icon--handle.svg'; + +class Dial extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleMouseDown', + 'handleMouseMove', + 'containerRef', + 'handleRef', + 'unbindMouseEvents' + ]); + } + + componentDidMount () { + // Manually add touch/mouse handlers so that preventDefault can be used + // to prevent scrolling on touch. + // Tracked as a react issue https://github.com/facebook/react/issues/6436 + this.handleElement.addEventListener('mousedown', this.handleMouseDown); + this.handleElement.addEventListener('touchstart', this.handleMouseDown); + } + + componentWillUnmount () { + this.unbindMouseEvents(); + this.handleElement.removeEventListener('mousedown', this.handleMouseDown); + this.handleElement.removeEventListener('touchstart', this.handleMouseDown); + } + + /** + * Get direction from dial center to mouse move event. + * @param {Event} e - Mouse move event. + * @returns {number} Direction in degrees, clockwise, 90=horizontal. + */ + directionToMouseEvent (e) { + const {x: mx, y: my} = getEventXY(e); + const bbox = this.containerElement.getBoundingClientRect(); + const cy = bbox.top + (bbox.height / 2); + const cx = bbox.left + (bbox.width / 2); + const angle = Math.atan2(my - cy, mx - cx); + const degrees = angle * (180 / Math.PI); + return degrees + 90; // To correspond with scratch coordinate system + } + + /** + * Create SVG path data string for the dial "gauge", the overlaid arc slice. + * @param {number} radius - The radius of the dial. + * @param {number} direction - Direction in degrees, clockwise, 90=horizontal. + * @returns {string} Path data string for the gauge. + */ + gaugePath (radius, direction) { + const rads = (direction) * (Math.PI / 180); + const path = []; + path.push(`M ${radius} 0`); + path.push(`L ${radius} ${radius}`); + path.push(`L ${radius + (radius * Math.sin(rads))} ${radius - (radius * Math.cos(rads))}`); + path.push(`A ${radius} ${radius} 0 0 ${direction < 0 ? 1 : 0} ${radius} 0`); + path.push(`Z`); + return path.join(' '); + } + + handleMouseMove (e) { + this.props.onChange(this.directionToMouseEvent(e) + this.directionOffset); + e.preventDefault(); + } + + unbindMouseEvents () { + window.removeEventListener('mousemove', this.handleMouseMove); + window.removeEventListener('mouseup', this.unbindMouseEvents); + window.removeEventListener('touchmove', this.handleMouseMove); + window.removeEventListener('touchend', this.unbindMouseEvents); + } + + handleMouseDown (e) { + // Because the drag handle is not a single point, there is some initial + // difference between the current sprite direction and the direction to the mouse + // Store this offset to prevent jumping when the mouse is moved. + this.directionOffset = this.props.direction - this.directionToMouseEvent(e); + window.addEventListener('mousemove', this.handleMouseMove); + window.addEventListener('mouseup', this.unbindMouseEvents); + window.addEventListener('touchmove', this.handleMouseMove); + window.addEventListener('touchend', this.unbindMouseEvents); + e.preventDefault(); + } + + containerRef (el) { + this.containerElement = el; + } + + handleRef (el) { + this.handleElement = el; + } + + render () { + const {direction, radius} = this.props; + return ( + <div className={styles.container}> + <div + className={styles.dialContainer} + ref={this.containerRef} + style={{ + width: `${radius * 2}px`, + height: `${radius * 2}px` + }} + > + <img + className={styles.dialFace} + draggable={false} + src={dialFace} + /> + <svg + className={styles.gauge} + height={radius * 2} + width={radius * 2} + > + <path + className={styles.gaugePath} + d={this.gaugePath(radius, direction)} + /> + </svg> + <img + className={styles.dialHandle} + draggable={false} + ref={this.handleRef} + src={dialHandle} + style={{ + top: `${radius - (radius * Math.cos(direction * (Math.PI / 180)))}px`, + left: `${radius + (radius * Math.sin(direction * (Math.PI / 180)))}px`, + transform: `rotate(${direction}deg)` + }} + /> + </div> + </div> + ); + } +} + +Dial.propTypes = { + direction: PropTypes.number, + onChange: PropTypes.func.isRequired, + radius: PropTypes.number +}; + +Dial.defaultProps = { + direction: 90, // degrees + radius: 56 // px +}; + +export default Dial; diff --git a/src/components/direction-picker/direction-picker.css b/src/components/direction-picker/direction-picker.css new file mode 100644 index 0000000000000000000000000000000000000000..501ff02171425a3a229e56b789823a748b2d4554 --- /dev/null +++ b/src/components/direction-picker/direction-picker.css @@ -0,0 +1,32 @@ +@import "../../css/colors.css"; + +.button-row { + display: flex; + flex-direction: row; + justify-content: center; + +} + +.icon-button { + margin: 0.25rem; + border: none; + background: none; + outline: none; + cursor: pointer; + user-select: none; +} + +.icon-button:active > img { + width: 20px; + height: 20px; + transform: scale(1.15); +} + +.icon-button > img { + transition: transform 0.1s; + filter: grayscale(100%); +} + +.icon-button.active > img { + filter: none; +} diff --git a/src/components/direction-picker/direction-picker.jsx b/src/components/direction-picker/direction-picker.jsx new file mode 100644 index 0000000000000000000000000000000000000000..626957381abf5d42680e4580ce49d557cde06a49 --- /dev/null +++ b/src/components/direction-picker/direction-picker.jsx @@ -0,0 +1,142 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Popover from 'react-popover'; +import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'react-intl'; + +import Label from '../forms/label.jsx'; +import Input from '../forms/input.jsx'; +import BufferedInputHOC from '../forms/buffered-input-hoc.jsx'; +import Dial from './dial.jsx'; + +import styles from './direction-picker.css'; + +import allAroundIcon from './icon--all-around.svg'; +import leftRightIcon from './icon--left-right.svg'; +import dontRotateIcon from './icon--dont-rotate.svg'; + +const BufferedInput = BufferedInputHOC(Input); + +const directionLabel = ( + <FormattedMessage + defaultMessage="Direction" + description="Sprite info direction label" + id="gui.SpriteInfo.direction" + /> +); + +const RotationStyles = { + ALL_AROUND: 'all around', + LEFT_RIGHT: 'left-right', + DONT_ROTATE: "don't rotate" +}; + +const messages = defineMessages({ + allAround: { + id: 'gui.directionPicker.rotationStyles.allAround', + description: 'Button to change to the all around rotation style', + defaultMessage: 'All Around' + }, + leftRight: { + id: 'gui.directionPicker.rotationStyles.leftRight', + description: 'Button to change to the left-right rotation style', + defaultMessage: 'Left/Right' + }, + dontRotate: { + id: 'gui.directionPicker.rotationStyles.dontRotate', + description: 'Button to change to the dont rotate rotation style', + defaultMessage: 'Do not rotate' + } +}); + +const DirectionPicker = props => ( + <Label + secondary + text={directionLabel} + > + <Popover + body={ + <div> + <Dial + direction={props.direction} + onChange={props.onChangeDirection} + /> + <div className={styles.buttonRow}> + <button + className={classNames(styles.iconButton, { + [styles.active]: props.rotationStyle === RotationStyles.ALL_AROUND + })} + title={props.intl.formatMessage(messages.allAround)} + onClick={props.onClickAllAround} + > + <img + draggable={false} + src={allAroundIcon} + /> + </button> + <button + className={classNames(styles.iconButton, { + [styles.active]: props.rotationStyle === RotationStyles.LEFT_RIGHT + })} + title={props.intl.formatMessage(messages.leftRight)} + onClick={props.onClickLeftRight} + > + <img + draggable={false} + src={leftRightIcon} + /> + </button> + <button + className={classNames(styles.iconButton, { + [styles.active]: props.rotationStyle === RotationStyles.DONT_ROTATE + })} + title={props.intl.formatMessage(messages.dontRotate)} + onClick={props.onClickDontRotate} + > + <img + draggable={false} + src={dontRotateIcon} + /> + </button> + </div> + </div> + } + isOpen={props.popoverOpen} + preferPlace="above" + onOuterAction={props.onClosePopover} + > + <BufferedInput + small + disabled={props.disabled} + label={directionLabel} + tabIndex="0" + type="text" + value={props.disabled ? '' : props.direction} + onFocus={props.onOpenPopover} + onSubmit={props.onChangeDirection} + /> + </Popover> + </Label> + +); + +DirectionPicker.propTypes = { + direction: PropTypes.number, + disabled: PropTypes.bool.isRequired, + intl: intlShape, + onChangeDirection: PropTypes.func.isRequired, + onClickAllAround: PropTypes.func.isRequired, + onClickDontRotate: PropTypes.func.isRequired, + onClickLeftRight: PropTypes.func.isRequired, + onClosePopover: PropTypes.func.isRequired, + onOpenPopover: PropTypes.func.isRequired, + popoverOpen: PropTypes.bool.isRequired, + rotationStyle: PropTypes.string +}; + +const WrappedDirectionPicker = injectIntl(DirectionPicker); + +export { + WrappedDirectionPicker as default, + RotationStyles +}; diff --git a/src/components/direction-picker/icon--all-around.svg b/src/components/direction-picker/icon--all-around.svg new file mode 100644 index 0000000000000000000000000000000000000000..2412c0b2327c3db488418d978ad382fa72335702 Binary files /dev/null and b/src/components/direction-picker/icon--all-around.svg differ diff --git a/src/components/direction-picker/icon--dial.svg b/src/components/direction-picker/icon--dial.svg new file mode 100644 index 0000000000000000000000000000000000000000..d4aa8ed8205a7d51ee4c78086e409c2ef3248ff5 Binary files /dev/null and b/src/components/direction-picker/icon--dial.svg differ diff --git a/src/components/direction-picker/icon--dont-rotate.svg b/src/components/direction-picker/icon--dont-rotate.svg new file mode 100644 index 0000000000000000000000000000000000000000..4796c03af2562424f84487a4d8522ea16f20537c Binary files /dev/null and b/src/components/direction-picker/icon--dont-rotate.svg differ diff --git a/src/components/direction-picker/icon--handle.svg b/src/components/direction-picker/icon--handle.svg new file mode 100644 index 0000000000000000000000000000000000000000..8e5fee6e0ba3c7edd111b8a52e8fd58cb6239ddd Binary files /dev/null and b/src/components/direction-picker/icon--handle.svg differ diff --git a/src/components/direction-picker/icon--left-right.svg b/src/components/direction-picker/icon--left-right.svg new file mode 100644 index 0000000000000000000000000000000000000000..4525bd6e917188b4f348b4215d4f00a15955f78d Binary files /dev/null and b/src/components/direction-picker/icon--left-right.svg differ diff --git a/src/components/sprite-info/sprite-info.jsx b/src/components/sprite-info/sprite-info.jsx index 37e18735a8c6b2bddbba1443f356b820f50ca321..b46b889a4a87a500a05e43500825aa04d1ce0eda 100644 --- a/src/components/sprite-info/sprite-info.jsx +++ b/src/components/sprite-info/sprite-info.jsx @@ -6,6 +6,8 @@ import Box from '../box/box.jsx'; import Label from '../forms/label.jsx'; import Input from '../forms/input.jsx'; import BufferedInputHOC from '../forms/buffered-input-hoc.jsx'; +import DirectionPicker from '../../containers/direction-picker.jsx'; + import {injectIntl, intlShape, defineMessages, FormattedMessage} from 'react-intl'; import {STAGE_DISPLAY_SIZES} from '../../lib/layout-constants.js'; @@ -29,6 +31,7 @@ const messages = defineMessages({ class SpriteInfo extends React.Component { shouldComponentUpdate (nextProps) { return ( + this.props.rotationStyle !== nextProps.rotationStyle || this.props.direction !== nextProps.direction || this.props.disabled !== nextProps.disabled || this.props.name !== nextProps.name || @@ -65,13 +68,6 @@ class SpriteInfo extends React.Component { id="gui.SpriteInfo.size" /> ); - const directionLabel = ( - <FormattedMessage - defaultMessage="Direction" - description="Sprite info direction label" - id="gui.SpriteInfo.direction" - /> - ); const spriteNameInput = ( <BufferedInput @@ -234,20 +230,13 @@ class SpriteInfo extends React.Component { </Label> </div> <div className={classNames(styles.group, styles.largerInput)}> - <Label - secondary - text={directionLabel} - > - <BufferedInput - small - disabled={this.props.disabled} - label={directionLabel} - tabIndex="0" - type="text" - value={this.props.disabled ? '' : this.props.direction} - onSubmit={this.props.onChangeDirection} - /> - </Label> + <DirectionPicker + direction={this.props.direction} + disabled={this.props.disabled} + rotationStyle={this.props.rotationStyle} + onChangeDirection={this.props.onChangeDirection} + onChangeRotationStyle={this.props.onChangeRotationStyle} + /> </div> </div> </Box> @@ -265,6 +254,7 @@ SpriteInfo.propTypes = { name: PropTypes.string, onChangeDirection: PropTypes.func, onChangeName: PropTypes.func, + onChangeRotationStyle: PropTypes.func, onChangeSize: PropTypes.func, onChangeX: PropTypes.func, onChangeY: PropTypes.func, @@ -272,6 +262,7 @@ SpriteInfo.propTypes = { onClickVisible: PropTypes.func, onPressNotVisible: PropTypes.func, onPressVisible: PropTypes.func, + rotationStyle: PropTypes.string, size: PropTypes.oneOfType([ PropTypes.string, PropTypes.number diff --git a/src/components/sprite-selector/sprite-selector.jsx b/src/components/sprite-selector/sprite-selector.jsx index e6948c5b7d85c08720a24625425e560c345811aa..1ab064d38c3e6aecf7ead60cd394d58051fbf7d6 100644 --- a/src/components/sprite-selector/sprite-selector.jsx +++ b/src/components/sprite-selector/sprite-selector.jsx @@ -47,6 +47,7 @@ const SpriteSelectorComponent = function (props) { intl, onChangeSpriteDirection, onChangeSpriteName, + onChangeSpriteRotationStyle, onChangeSpriteSize, onChangeSpriteVisibility, onChangeSpriteX, @@ -84,6 +85,7 @@ const SpriteSelectorComponent = function (props) { direction={selectedSprite.direction} disabled={spriteInfoDisabled} name={selectedSprite.name} + rotationStyle={selectedSprite.rotationStyle} size={selectedSprite.size} stageSize={stageSize} visible={selectedSprite.visible} @@ -91,6 +93,7 @@ const SpriteSelectorComponent = function (props) { y={selectedSprite.y} onChangeDirection={onChangeSpriteDirection} onChangeName={onChangeSpriteName} + onChangeRotationStyle={onChangeSpriteRotationStyle} onChangeSize={onChangeSpriteSize} onChangeVisibility={onChangeSpriteVisibility} onChangeX={onChangeSpriteX} @@ -152,6 +155,7 @@ SpriteSelectorComponent.propTypes = { intl: intlShape.isRequired, onChangeSpriteDirection: PropTypes.func, onChangeSpriteName: PropTypes.func, + onChangeSpriteRotationStyle: PropTypes.func, onChangeSpriteSize: PropTypes.func, onChangeSpriteVisibility: PropTypes.func, onChangeSpriteX: PropTypes.func, diff --git a/src/components/target-pane/target-pane.jsx b/src/components/target-pane/target-pane.jsx index c620c8504c13a1dbd2034a9a2d3a72f80e3d8b85..565c9881cf405e6cb3856eb38671e69995a2ac2f 100644 --- a/src/components/target-pane/target-pane.jsx +++ b/src/components/target-pane/target-pane.jsx @@ -23,6 +23,7 @@ const TargetPane = ({ spriteLibraryVisible, onChangeSpriteDirection, onChangeSpriteName, + onChangeSpriteRotationStyle, onChangeSpriteSize, onChangeSpriteVisibility, onChangeSpriteX, @@ -60,6 +61,7 @@ const TargetPane = ({ stageSize={stageSize} onChangeSpriteDirection={onChangeSpriteDirection} onChangeSpriteName={onChangeSpriteName} + onChangeSpriteRotationStyle={onChangeSpriteRotationStyle} onChangeSpriteSize={onChangeSpriteSize} onChangeSpriteVisibility={onChangeSpriteVisibility} onChangeSpriteX={onChangeSpriteX} @@ -128,6 +130,7 @@ TargetPane.propTypes = { }), onChangeSpriteDirection: PropTypes.func, onChangeSpriteName: PropTypes.func, + onChangeSpriteRotationStyle: PropTypes.func, onChangeSpriteSize: PropTypes.func, onChangeSpriteVisibility: PropTypes.func, onChangeSpriteX: PropTypes.func, diff --git a/src/containers/direction-picker.jsx b/src/containers/direction-picker.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a0643d706c8aaaa9600a08aea4ecd2c986ba5659 --- /dev/null +++ b/src/containers/direction-picker.jsx @@ -0,0 +1,62 @@ +import bindAll from 'lodash.bindall'; +import PropTypes from 'prop-types'; +import React from 'react'; + +import DirectionComponent, {RotationStyles} from '../components/direction-picker/direction-picker.jsx'; + +class DirectionPicker extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleOpenPopover', + 'handleClosePopover', + 'handleClickLeftRight', + 'handleClickDontRotate', + 'handleClickAllAround' + ]); + this.state = { + popoverOpen: false + }; + } + handleOpenPopover () { + this.setState({popoverOpen: true}); + } + handleClosePopover () { + this.setState({popoverOpen: false}); + } + handleClickAllAround () { + this.props.onChangeRotationStyle(RotationStyles.ALL_AROUND); + } + handleClickLeftRight () { + this.props.onChangeRotationStyle(RotationStyles.LEFT_RIGHT); + } + handleClickDontRotate () { + this.props.onChangeRotationStyle(RotationStyles.DONT_ROTATE); + } + render () { + return ( + <DirectionComponent + direction={this.props.direction} + disabled={this.props.disabled} + popoverOpen={this.state.popoverOpen && !this.props.disabled} + rotationStyle={this.props.rotationStyle} + onChangeDirection={this.props.onChangeDirection} + onClickAllAround={this.handleClickAllAround} + onClickDontRotate={this.handleClickDontRotate} + onClickLeftRight={this.handleClickLeftRight} + onClosePopover={this.handleClosePopover} + onOpenPopover={this.handleOpenPopover} + /> + ); + } +} + +DirectionPicker.propTypes = { + direction: PropTypes.number, + disabled: PropTypes.bool, + onChangeDirection: PropTypes.func, + onChangeRotationStyle: PropTypes.func, + rotationStyle: PropTypes.string +}; + +export default DirectionPicker; diff --git a/src/containers/target-pane.jsx b/src/containers/target-pane.jsx index a116b3c0788127d6743e7555ecb1cdb34016d97d..b82d7c038cf15d2c6ec73feed0124e79b147ae5d 100644 --- a/src/containers/target-pane.jsx +++ b/src/containers/target-pane.jsx @@ -20,6 +20,7 @@ class TargetPane extends React.Component { super(props); bindAll(this, [ 'handleBlockDragEnd', + 'handleChangeSpriteRotationStyle', 'handleChangeSpriteDirection', 'handleChangeSpriteName', 'handleChangeSpriteSize', @@ -48,6 +49,9 @@ class TargetPane extends React.Component { handleChangeSpriteDirection (direction) { this.props.vm.postSpriteInfo({direction}); } + handleChangeSpriteRotationStyle (rotationStyle) { + this.props.vm.postSpriteInfo({rotationStyle}); + } handleChangeSpriteName (name) { this.props.vm.renameSprite(this.props.editingTarget, name); } @@ -167,6 +171,7 @@ class TargetPane extends React.Component { fileInputRef={this.setFileInput} onChangeSpriteDirection={this.handleChangeSpriteDirection} onChangeSpriteName={this.handleChangeSpriteName} + onChangeSpriteRotationStyle={this.handleChangeSpriteRotationStyle} onChangeSpriteSize={this.handleChangeSpriteSize} onChangeSpriteVisibility={this.handleChangeSpriteVisibility} onChangeSpriteX={this.handleChangeSpriteX}