diff --git a/src/components/paint-editor.jsx b/src/components/paint-editor.jsx index b565eff8..da5fcd3f 100644 --- a/src/components/paint-editor.jsx +++ b/src/components/paint-editor.jsx @@ -3,6 +3,7 @@ import React from 'react'; import PaperCanvas from '../containers/paper-canvas.jsx'; import BrushMode from '../containers/brush-mode.jsx'; import EraserMode from '../containers/eraser-mode.jsx'; +import ReshapeMode from '../containers/reshape-mode.jsx'; import SelectMode from '../containers/select-mode.jsx'; import PropTypes from 'prop-types'; import LineMode from '../containers/line-mode.jsx'; @@ -130,6 +131,9 @@ class PaintEditorComponent extends React.Component { + ) : null} diff --git a/src/components/reshape-mode.jsx b/src/components/reshape-mode.jsx new file mode 100644 index 00000000..a2b06f40 --- /dev/null +++ b/src/components/reshape-mode.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import {FormattedMessage} from 'react-intl'; + +const ReshapeModeComponent = props => ( + +); + +ReshapeModeComponent.propTypes = { + onMouseDown: PropTypes.func.isRequired +}; + +export default ReshapeModeComponent; diff --git a/src/containers/paper-canvas.jsx b/src/containers/paper-canvas.jsx index 50e95b51..ab6dc3e2 100644 --- a/src/containers/paper-canvas.jsx +++ b/src/containers/paper-canvas.jsx @@ -15,6 +15,8 @@ class PaperCanvas extends React.Component { } componentDidMount () { paper.setup(this.canvas); + // Don't show handles by default + paper.settings.handleSize = 0; if (this.props.svg) { this.importSvg(this.props.svg, this.props.rotationCenterX, this.props.rotationCenterY); } diff --git a/src/containers/reshape-mode.jsx b/src/containers/reshape-mode.jsx new file mode 100644 index 00000000..1f729841 --- /dev/null +++ b/src/containers/reshape-mode.jsx @@ -0,0 +1,203 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import {connect} from 'react-redux'; +import bindAll from 'lodash.bindall'; +import Modes from '../modes/modes'; + +import {changeMode} from '../reducers/modes'; +import {setHoveredItem, clearHoveredItem} from '../reducers/hover'; + +import {getHoveredItem} from '../helper/hover'; +import {rectSelect} from '../helper/guides'; +import {processRectangularSelection} from '../helper/selection'; + +import ReshapeModeComponent from '../components/reshape-mode.jsx'; +import BoundingBoxTool from '../helper/bounding-box/bounding-box-tool'; +import paper from 'paper'; + +class ReshapeMode extends React.Component { + static get TOLERANCE () { + return 8; + } + constructor (props) { + super(props); + bindAll(this, [ + 'activateTool', + 'deactivateTool', + 'getHitOptions' + ]); + + this._hitOptionsSelected = { + match: function (item) { + if (!item.item || !item.item.selected) return; + if (item.type === 'handle-out' || item.type === 'handle-in') { + // Only hit test against handles that are visible, that is, + // their segment is selected + if (!item.segment.selected) { + return false; + } + // If the entire shape is selected, handles are hidden + if (item.item.fullySelected) { + return false; + } + } + return true; + }, + segments: true, + stroke: true, + curves: true, + handles: true, + fill: true, + guide: false + }; + this._hitOptions = { + match: function (item) { + if (item.type === 'handle-out' || item.type === 'handle-in') { + // Only hit test against handles that are visible, that is, + // their segment is selected + if (!item.segment.selected) { + return false; + } + // If the entire shape is selected, handles are hidden + if (item.item.fullySelected) { + return false; + } + } + return true; + }, + segments: true, + stroke: true, + curves: true, + handles: true, + fill: true, + guide: false + }; + this.boundingBoxTool = new BoundingBoxTool(); + this.selectionBoxMode = false; + this.selectionRect = null; + } + componentDidMount () { + if (this.props.isReshapeModeActive) { + this.activateTool(this.props); + } + } + componentWillReceiveProps (nextProps) { + if (nextProps.isReshapeModeActive && !this.props.isReshapeModeActive) { + this.activateTool(); + } else if (!nextProps.isReshapeModeActive && this.props.isReshapeModeActive) { + this.deactivateTool(); + } + } + shouldComponentUpdate () { + return false; // Static component, for now + } + getHitOptions (preselectedOnly) { + this._hitOptions.tolerance = ReshapeMode.TOLERANCE / paper.view.zoom; + this._hitOptionsSelected.tolerance = ReshapeMode.TOLERANCE / paper.view.zoom; + return preselectedOnly ? this._hitOptionsSelected : this._hitOptions; + } + activateTool () { + paper.settings.handleSize = 8; + this.boundingBoxTool.setSelectionBounds(); + this.tool = new paper.Tool(); + + const reshapeMode = this; + + this.tool.onMouseDown = function (event) { + if (event.event.button > 0) return; // only first mouse button + + reshapeMode.props.clearHoveredItem(); + if (!reshapeMode.boundingBoxTool + .onMouseDown( + event, + event.modifiers.alt, + event.modifiers.shift, + reshapeMode.getHitOptions(false /* preseelectedOnly */))) { + reshapeMode.selectionBoxMode = true; + } + }; + + this.tool.onMouseMove = function (event) { + const hoveredItem = getHoveredItem(event, reshapeMode.getHitOptions()); + const oldHoveredItem = reshapeMode.props.hoveredItem; + if ((!hoveredItem && oldHoveredItem) || // There is no longer a hovered item + (hoveredItem && !oldHoveredItem) || // There is now a hovered item + (hoveredItem && oldHoveredItem && hoveredItem.id !== oldHoveredItem.id)) { // hovered item changed + reshapeMode.props.setHoveredItem(hoveredItem); + } + }; + + + this.tool.onMouseDrag = function (event) { + if (event.event.button > 0) return; // only first mouse button + + if (reshapeMode.selectionBoxMode) { + reshapeMode.selectionRect = rectSelect(event); + // Remove this rect on the next drag and up event + reshapeMode.selectionRect.removeOnDrag(); + } else { + reshapeMode.boundingBoxTool.onMouseDrag(event); + } + }; + + this.tool.onMouseUp = function (event) { + if (event.event.button > 0) return; // only first mouse button + + if (reshapeMode.selectionBoxMode) { + if (reshapeMode.selectionRect) { + processRectangularSelection(event, reshapeMode.selectionRect, Modes.RESHAPE); + reshapeMode.selectionRect.remove(); + } + reshapeMode.boundingBoxTool.setSelectionBounds(); + } else { + reshapeMode.boundingBoxTool.onMouseUp(event); + reshapeMode.props.onUpdateSvg(); + } + reshapeMode.selectionBoxMode = false; + reshapeMode.selectionRect = null; + }; + this.tool.activate(); + } + deactivateTool () { + paper.settings.handleSize = 0; + this.props.clearHoveredItem(); + this.tool.remove(); + this.tool = null; + this.hitResult = null; + } + render () { + return ( + + ); + } +} + +ReshapeMode.propTypes = { + clearHoveredItem: PropTypes.func.isRequired, + handleMouseDown: PropTypes.func.isRequired, + hoveredItem: PropTypes.instanceOf(paper.Item), // eslint-disable-line react/no-unused-prop-types + isReshapeModeActive: PropTypes.bool.isRequired, + onUpdateSvg: PropTypes.func.isRequired, // eslint-disable-line react/no-unused-prop-types + setHoveredItem: PropTypes.func.isRequired // eslint-disable-line react/no-unused-prop-types +}; + +const mapStateToProps = state => ({ + isReshapeModeActive: state.scratchPaint.mode === Modes.RESHAPE, + hoveredItem: state.scratchPaint.hoveredItem +}); +const mapDispatchToProps = dispatch => ({ + setHoveredItem: hoveredItem => { + dispatch(setHoveredItem(hoveredItem)); + }, + clearHoveredItem: () => { + dispatch(clearHoveredItem()); + }, + handleMouseDown: () => { + dispatch(changeMode(Modes.RESHAPE)); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(ReshapeMode); diff --git a/test/unit/components/reshape-mode.test.jsx b/test/unit/components/reshape-mode.test.jsx new file mode 100644 index 00000000..a6c71a3a --- /dev/null +++ b/test/unit/components/reshape-mode.test.jsx @@ -0,0 +1,15 @@ +/* eslint-env jest */ +import React from 'react'; // eslint-disable-line no-unused-vars +import {shallow} from 'enzyme'; +import ReshapeModeComponent from '../../../src/components/reshape-mode.jsx'; // eslint-disable-line no-unused-vars + +describe('ReshapeModeComponent', () => { + test('triggers callback when clicked', () => { + const onClick = jest.fn(); + const componentShallowWrapper = shallow( + + ); + componentShallowWrapper.simulate('click'); + expect(onClick).toHaveBeenCalled(); + }); +});