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 7539bb0d..ae4eda24 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..2fb3dc55
--- /dev/null
+++ b/src/containers/reshape-mode.jsx
@@ -0,0 +1,86 @@
+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 ReshapeTool from '../helper/selection-tools/reshape-tool';
+import ReshapeModeComponent from '../components/reshape-mode.jsx';
+
+class ReshapeMode extends React.Component {
+ constructor (props) {
+ super(props);
+ bindAll(this, [
+ 'activateTool',
+ 'deactivateTool'
+ ]);
+ }
+ componentDidMount () {
+ if (this.props.isReshapeModeActive) {
+ this.activateTool(this.props);
+ }
+ }
+ componentWillReceiveProps (nextProps) {
+ if (this.tool && nextProps.hoveredItemId !== this.props.hoveredItemId) {
+ this.tool.setPrevHoveredItemId(nextProps.hoveredItemId);
+ }
+
+ if (nextProps.isReshapeModeActive && !this.props.isReshapeModeActive) {
+ this.activateTool();
+ } else if (!nextProps.isReshapeModeActive && this.props.isReshapeModeActive) {
+ this.deactivateTool();
+ }
+ }
+ shouldComponentUpdate () {
+ return false; // Static component, for now
+ }
+ activateTool () {
+ this.tool = new ReshapeTool(this.props.setHoveredItem, this.props.clearHoveredItem, this.props.onUpdateSvg);
+ this.tool.setPrevHoveredItemId(this.props.hoveredItemId);
+ this.tool.activate();
+ }
+ deactivateTool () {
+ this.tool.deactivateTool();
+ this.tool.remove();
+ this.tool = null;
+ this.hitResult = null;
+ }
+ render () {
+ return (
+
+ );
+ }
+}
+
+ReshapeMode.propTypes = {
+ clearHoveredItem: PropTypes.func.isRequired,
+ handleMouseDown: PropTypes.func.isRequired,
+ hoveredItemId: PropTypes.number,
+ isReshapeModeActive: PropTypes.bool.isRequired,
+ onUpdateSvg: PropTypes.func.isRequired,
+ setHoveredItem: PropTypes.func.isRequired
+};
+
+const mapStateToProps = state => ({
+ isReshapeModeActive: state.scratchPaint.mode === Modes.RESHAPE,
+ hoveredItemId: state.scratchPaint.hoveredItemId
+});
+const mapDispatchToProps = dispatch => ({
+ setHoveredItem: hoveredItemId => {
+ dispatch(setHoveredItem(hoveredItemId));
+ },
+ clearHoveredItem: () => {
+ dispatch(clearHoveredItem());
+ },
+ handleMouseDown: () => {
+ dispatch(changeMode(Modes.RESHAPE));
+ }
+});
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(ReshapeMode);
diff --git a/src/helper/hover.js b/src/helper/hover.js
index b4c0bc1f..eee1b3b5 100644
--- a/src/helper/hover.js
+++ b/src/helper/hover.js
@@ -6,9 +6,11 @@ import {isGroupChild} from './group';
/**
* @param {!MouseEvent} event mouse event
* @param {?object} hitOptions hit options to use
+ * @param {?boolean} subselect Whether items within groups can be hovered. If false, the
+ * entire group should be hovered.
* @return {paper.Item} the hovered item or null if there is none
*/
-const getHoveredItem = function (event, hitOptions) {
+const getHoveredItem = function (event, hitOptions, subselect) {
const hitResults = paper.project.hitTestAll(event.point, hitOptions);
if (hitResults.length === 0) {
return null;
@@ -27,7 +29,7 @@ const getHoveredItem = function (event, hitOptions) {
if (isBoundsItem(hitResult.item)) {
return hoverBounds(hitResult.item);
- } else if (isGroupChild(hitResult.item)) {
+ } else if (!subselect && isGroupChild(hitResult.item)) {
return hoverBounds(getRootItem(hitResult.item));
}
return hoverItem(hitResult);
diff --git a/src/helper/selection-tools/handle-tool.js b/src/helper/selection-tools/handle-tool.js
new file mode 100644
index 00000000..26aead16
--- /dev/null
+++ b/src/helper/selection-tools/handle-tool.js
@@ -0,0 +1,70 @@
+import {clearSelection, getSelectedItems} from '../selection';
+
+/** Sub tool of the Reshape tool for moving handles, which adjust bezier curves. */
+class HandleTool {
+ /**
+ * @param {!function} onUpdateSvg A callback to call when the image visibly changes
+ */
+ constructor (onUpdateSvg) {
+ this.hitType = null;
+ this.onUpdateSvg = onUpdateSvg;
+ }
+ /**
+ * @param {!object} hitProperties Describes the mouse event
+ * @param {?boolean} hitProperties.multiselect Whether to multiselect on mouse down (e.g. shift key held)
+ * select the whole group.
+ */
+ onMouseDown (hitProperties) {
+ if (!hitProperties.multiselect) {
+ clearSelection();
+ }
+
+ hitProperties.hitResult.segment.handleIn.selected = true;
+ hitProperties.hitResult.segment.handleOut.selected = true;
+ this.hitType = hitProperties.hitResult.type;
+ }
+ onMouseDrag (event) {
+ const selectedItems = getSelectedItems(true /* recursive */);
+
+ for (const item of selectedItems) {
+ for (const seg of item.segments) {
+ // add the point of the segment before the drag started
+ // for later use in the snap calculation
+ if (!seg.origPoint) {
+ seg.origPoint = seg.point.clone();
+ }
+
+ if (seg.handleOut.selected && this.hitType === 'handle-out'){
+ // if option is pressed or handles have been split,
+ // they're no longer parallel and move independently
+ if (event.modifiers.option ||
+ !seg.handleOut.isColinear(seg.handleIn)) {
+ seg.handleOut = seg.handleOut.add(event.delta);
+ } else {
+ const oldLength = seg.handleOut.length;
+ seg.handleOut = seg.handleOut.add(event.delta);
+ seg.handleIn = seg.handleOut.multiply(-seg.handleIn.length / oldLength);
+ }
+ } else if (seg.handleIn.selected && this.hitType === 'handle-in') {
+ // if option is pressed or handles have been split,
+ // they're no longer parallel and move independently
+ if (event.modifiers.option ||
+ !seg.handleOut.isColinear(seg.handleIn)) {
+ seg.handleIn = seg.handleIn.add(event.delta);
+
+ } else {
+ const oldLength = seg.handleIn.length;
+ seg.handleIn = seg.handleIn.add(event.delta);
+ seg.handleOut = seg.handleIn.multiply(-seg.handleOut.length / oldLength);
+ }
+ }
+ }
+ }
+ }
+ onMouseUp () {
+ // @todo add back undo
+ this.onUpdateSvg();
+ }
+}
+
+export default HandleTool;
diff --git a/src/helper/selection-tools/point-tool.js b/src/helper/selection-tools/point-tool.js
new file mode 100644
index 00000000..12aa53ae
--- /dev/null
+++ b/src/helper/selection-tools/point-tool.js
@@ -0,0 +1,196 @@
+import paper from 'paper';
+import {snapDeltaToAngle} from '../math';
+import {clearSelection, getSelectedItems} from '../selection';
+
+/** Subtool of ReshapeTool for moving control points. */
+class PointTool {
+ /**
+ * @param {!function} onUpdateSvg A callback to call when the image visibly changes
+ */
+ constructor (onUpdateSvg) {
+ /**
+ * Deselection often does not happen until mouse up. If the mouse is dragged before
+ * mouse up, deselection is cancelled. This variable keeps track of which paper.Item to deselect.
+ */
+ this.deselectOnMouseUp = null;
+ /**
+ * Delete control point does not happen until mouse up. If the mouse is dragged before
+ * mouse up, delete is cancelled. This variable keeps track of the hitResult that triggers delete.
+ */
+ this.deleteOnMouseUp = null;
+ /**
+ * There are 2 cases for deselection: Deselect this, or deselect everything but this.
+ * When invert deselect is true, deselect everything but the item in deselectOnMouseUp.
+ */
+ this.invertDeselect = false;
+ this.selectedItems = null;
+ this.onUpdateSvg = onUpdateSvg;
+ }
+
+ /**
+ * @param {!object} hitProperties Describes the mouse event
+ * @param {!paper.HitResult} hitProperties.hitResult Data about the location of the mouse click
+ * @param {?boolean} hitProperties.multiselect Whether to multiselect on mouse down (e.g. shift key held)
+ * @param {?boolean} hitProperties.doubleClicked Whether this is the second click in a short time
+ */
+ onMouseDown (hitProperties) {
+ // Remove point
+ if (hitProperties.doubleClicked) {
+ this.deleteOnMouseUp = hitProperties.hitResult;
+ }
+ if (hitProperties.hitResult.segment.selected) {
+ // selected points with no handles get handles if selected again
+ if (hitProperties.multiselect) {
+ this.deselectOnMouseUp = hitProperties.hitResult.segment;
+ } else {
+ this.deselectOnMouseUp = hitProperties.hitResult.segment;
+ this.invertDeselect = true;
+ hitProperties.hitResult.segment.selected = true;
+ }
+ } else {
+ if (!hitProperties.multiselect) {
+ clearSelection();
+ }
+ hitProperties.hitResult.segment.selected = true;
+ }
+
+ this.selectedItems = getSelectedItems(true /* recursive */);
+ }
+ /**
+ * @param {!object} hitProperties Describes the mouse event
+ * @param {!paper.HitResult} hitProperties.hitResult Data about the location of the mouse click
+ * @param {?boolean} hitProperties.multiselect Whether to multiselect on mouse down (e.g. shift key held)
+ */
+ addPoint (hitProperties) {
+ // Length of curve from previous point to new point
+ const beforeCurveLength = hitProperties.hitResult.location.curveOffset;
+ const afterCurveLength =
+ hitProperties.hitResult.location.curve.length - hitProperties.hitResult.location.curveOffset;
+
+ // Handle length based on curve length until next point
+ let handleIn = hitProperties.hitResult.location.tangent.multiply(-beforeCurveLength / 2);
+ let handleOut = hitProperties.hitResult.location.tangent.multiply(afterCurveLength / 2);
+ // Don't let one handle overwhelm the other (results in path doubling back on itself weirdly)
+ if (handleIn.length > 3 * handleOut.length) {
+ handleIn = handleIn.multiply(3 * handleOut.length / handleIn.length);
+ }
+ if (handleOut.length > 3 * handleIn.length) {
+ handleOut = handleOut.multiply(3 * handleIn.length / handleOut.length);
+ }
+
+ const beforeSegment = hitProperties.hitResult.item.segments[hitProperties.hitResult.location.index];
+ const afterSegment = hitProperties.hitResult.item.segments[hitProperties.hitResult.location.index + 1];
+
+ // Add segment
+ const newSegment = new paper.Segment(hitProperties.hitResult.location.point, handleIn, handleOut);
+ hitProperties.hitResult.item.insert(hitProperties.hitResult.location.index + 1, newSegment);
+ hitProperties.hitResult.segment = newSegment;
+ if (!hitProperties.multiselect) {
+ clearSelection();
+ }
+ newSegment.selected = true;
+
+ // Adjust handles of curve before and curve after to account for new curve length
+ if (beforeSegment && beforeSegment.handleOut) {
+ if (afterSegment) {
+ beforeSegment.handleOut =
+ beforeSegment.handleOut.multiply(beforeCurveLength / 2 / beforeSegment.handleOut.length);
+ } else {
+ beforeSegment.handleOut = null;
+ }
+ }
+ if (afterSegment && afterSegment.handleIn) {
+ if (beforeSegment) {
+ afterSegment.handleIn =
+ afterSegment.handleIn.multiply(afterCurveLength / 2 / afterSegment.handleIn.length);
+ } else {
+ afterSegment.handleIn = null;
+ }
+ }
+ }
+ removePoint (hitResult) {
+ const index = hitResult.segment.index;
+ hitResult.item.removeSegment(index);
+
+ // Adjust handles of curve before and curve after to account for new curve length
+ const beforeSegment = hitResult.item.segments[index - 1];
+ const afterSegment = hitResult.item.segments[index];
+ const curveLength = beforeSegment ? beforeSegment.curve ? beforeSegment.curve.length : null : null;
+ if (beforeSegment && beforeSegment.handleOut) {
+ if (afterSegment) {
+ beforeSegment.handleOut =
+ beforeSegment.handleOut.multiply(curveLength / 2 / beforeSegment.handleOut.length);
+ } else {
+ beforeSegment.handleOut = null;
+ }
+ }
+ if (afterSegment && afterSegment.handleIn) {
+ if (beforeSegment) {
+ afterSegment.handleIn = afterSegment.handleIn.multiply(curveLength / 2 / afterSegment.handleIn.length);
+ } else {
+ afterSegment.handleIn = null;
+ }
+ }
+ }
+ onMouseDrag (event) {
+ // A click will deselect, but a drag will not
+ this.deselectOnMouseUp = null;
+ this.invertDeselect = false;
+ this.deleteOnMouseUp = null;
+
+ const dragVector = event.point.subtract(event.downPoint);
+
+ for (const item of this.selectedItems) {
+ if (!item.segments) {
+ return;
+ }
+ for (const seg of item.segments) {
+ // add the point of the segment before the drag started
+ // for later use in the snap calculation
+ if (!seg.origPoint) {
+ seg.origPoint = seg.point.clone();
+ }
+ if (seg.selected) {
+ if (event.modifiers.shift) {
+ seg.point = seg.origPoint.add(snapDeltaToAngle(dragVector, Math.PI / 4));
+ } else {
+ seg.point = seg.point.add(event.delta);
+ }
+ }
+ }
+ }
+ }
+ onMouseUp () {
+ // resetting the items and segments origin points for the next usage
+ for (const item of this.selectedItems) {
+ if (!item.segments) {
+ return;
+ }
+ for (const seg of item.segments) {
+ seg.origPoint = null;
+ }
+ }
+
+ // If no drag occurred between mouse down and mouse up, then we can go through with deselect
+ // and delete
+ if (this.deselectOnMouseUp) {
+ if (this.invertDeselect) {
+ clearSelection();
+ this.deselectOnMouseUp.selected = true;
+ } else {
+ this.deselectOnMouseUp.selected = false;
+ }
+ this.deselectOnMouseUp = null;
+ this.invertDeselect = false;
+ }
+ if (this.deleteOnMouseUp) {
+ this.removePoint(this.deleteOnMouseUp);
+ this.deleteOnMouseUp = null;
+ }
+ this.selectedItems = null;
+ // @todo add back undo
+ this.onUpdateSvg();
+ }
+}
+
+export default PointTool;
diff --git a/src/helper/selection-tools/reshape-tool.js b/src/helper/selection-tools/reshape-tool.js
new file mode 100644
index 00000000..596ec0f3
--- /dev/null
+++ b/src/helper/selection-tools/reshape-tool.js
@@ -0,0 +1,235 @@
+import paper from 'paper';
+import log from '../../log/log';
+import keyMirror from 'keymirror';
+
+import Modes from '../../modes/modes';
+import {getHoveredItem} from '../hover';
+import {deleteSelection} from '../selection';
+import {getRootItem, isPGTextItem} from '../item';
+import MoveTool from './move-tool';
+import PointTool from './point-tool';
+import HandleTool from './handle-tool';
+import SelectionBoxTool from './selection-box-tool';
+
+/** Modes of the reshape tool, which can do many things depending on how it's used. */
+const ReshapeModes = keyMirror({
+ FILL: null,
+ POINT: null,
+ HANDLE: null,
+ SELECTION_BOX: null
+});
+
+/**
+ * paper.Tool to handle reshape mode, which allows manipulation of control points and
+ * handles of path items. Can be used to select items within groups and points within items.
+ * Reshape is made up of 4 tools:
+ * - Selection box tool, which is activated by clicking an empty area. Draws a box and selects
+ * points and curves inside it
+ * - Move tool, which translates items
+ * - Point tool, which translates, adds and removes points
+ * - Handle tool, which translates handles, changing the shape of curves
+ */
+class ReshapeTool extends paper.Tool {
+ /** Distance within which mouse is considered to be hitting an item */
+ static get TOLERANCE () {
+ return 8;
+ }
+ /** Clicks registered within this amount of time are registered as double clicks */
+ static get DOUBLE_CLICK_MILLIS () {
+ return 250;
+ }
+ /**
+ * @param {function} setHoveredItem Callback to set the hovered item
+ * @param {function} clearHoveredItem Callback to clear the hovered item
+ * @param {!function} onUpdateSvg A callback to call when the image visibly changes
+ */
+ constructor (setHoveredItem, clearHoveredItem, onUpdateSvg) {
+ super();
+ this.setHoveredItem = setHoveredItem;
+ this.clearHoveredItem = clearHoveredItem;
+ this.onUpdateSvg = onUpdateSvg;
+ this.prevHoveredItemId = null;
+ this.lastEvent = null;
+ this.mode = ReshapeModes.SELECTION_BOX;
+ this._modeMap = {};
+ this._modeMap[ReshapeModes.FILL] = new MoveTool(onUpdateSvg);
+ this._modeMap[ReshapeModes.POINT] = new PointTool(onUpdateSvg);
+ this._modeMap[ReshapeModes.HANDLE] = new HandleTool(onUpdateSvg);
+ this._modeMap[ReshapeModes.SELECTION_BOX] = new SelectionBoxTool(Modes.RESHAPE);
+
+ // We have to set these functions instead of just declaring them because
+ // paper.js tools hook up the listeners in the setter functions.
+ this.onMouseDown = this.handleMouseDown;
+ this.onMouseMove = this.handleMouseMove;
+ this.onMouseDrag = this.handleMouseDrag;
+ this.onMouseUp = this.handleMouseUp;
+ this.onKeyUp = this.handleKeyUp;
+
+ paper.settings.handleSize = 8;
+ }
+ /**
+ * Returns the hit options to use when conducting hit tests.
+ * @param {boolean} preselectedOnly True if we should only return results that are already
+ * selected.
+ * @return {object} See paper.Item.hitTest for definition of options
+ */
+ getHitOptions (preselectedOnly) {
+ const hitOptions = {
+ segments: true,
+ stroke: true,
+ curves: true,
+ handles: true,
+ fill: true,
+ guide: false,
+ tolerance: ReshapeTool.TOLERANCE / paper.view.zoom
+ };
+ if (preselectedOnly) {
+ hitOptions.match = 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;
+ }
+ }
+ return true;
+ };
+ } else {
+ hitOptions.match = 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;
+ }
+ }
+ return true;
+ };
+ }
+ return hitOptions;
+ }
+ /**
+ * To be called when the hovered item changes. When the select tool hovers over a
+ * new item, it compares against this to see if a hover item change event needs to
+ * be fired.
+ * @param {paper.Item} prevHoveredItemId ID of the highlight item that indicates the mouse is
+ * over a given item currently
+ */
+ setPrevHoveredItemId (prevHoveredItemId) {
+ this.prevHoveredItemId = prevHoveredItemId;
+ }
+ handleMouseDown (event) {
+ if (event.event.button > 0) return; // only first mouse button
+ this.clearHoveredItem();
+
+ // Check if double clicked
+ let doubleClicked = false;
+ if (this.lastEvent) {
+ if ((event.event.timeStamp - this.lastEvent.event.timeStamp) < ReshapeTool.DOUBLE_CLICK_MILLIS) {
+ doubleClicked = true;
+ } else {
+ doubleClicked = false;
+ }
+ }
+ this.lastEvent = event;
+
+ // Choose hit result to use ===========================================================
+ // Prefer hits on already selected items
+ let hitResults =
+ paper.project.hitTestAll(event.point, this.getHitOptions(true /* preselectedOnly */));
+ if (hitResults.length === 0) {
+ hitResults = paper.project.hitTestAll(event.point, this.getHitOptions());
+ }
+ if (hitResults.length === 0) {
+ this._modeMap[ReshapeModes.SELECTION_BOX].onMouseDown(event.modifiers.shift);
+ return;
+ }
+
+ // Prefer hits on segments to other types of hits, to make sure handles are movable.
+ let hitResult = hitResults[0];
+ for (let i = 0; i < hitResults.length; i++) {
+ if (hitResults[i].type === 'segment') {
+ hitResult = hitResults[i];
+ break;
+ }
+ }
+
+ // Don't allow detail-selection of PGTextItem
+ if (isPGTextItem(getRootItem(hitResult.item))) {
+ return;
+ }
+
+ const hitProperties = {
+ hitResult: hitResult,
+ clone: event.modifiers.alt,
+ multiselect: event.modifiers.shift,
+ doubleClicked: doubleClicked,
+ subselect: true
+ };
+
+ // If item is not yet selected, don't behave differently depending on if they clicked a segment
+ // or stroke (since those were invisible), just select the whole thing as if they clicked the fill.
+ if (!hitResult.item.selected ||
+ hitResult.type === 'fill' ||
+ (hitResult.type !== 'segment' && doubleClicked)) {
+ this.mode = ReshapeModes.FILL;
+ this._modeMap[this.mode].onMouseDown(hitProperties);
+ } else if (hitResult.type === 'segment') {
+ this.mode = ReshapeModes.POINT;
+ this._modeMap[this.mode].onMouseDown(hitProperties);
+ } else if (
+ hitResult.type === 'stroke' ||
+ hitResult.type === 'curve') {
+ this.mode = ReshapeModes.POINT;
+ this._modeMap[this.mode].addPoint(hitProperties);
+ this._modeMap[this.mode].onMouseDown(hitProperties);
+ } else if (
+ hitResult.type === 'handle-in' ||
+ hitResult.type === 'handle-out') {
+ this.mode = ReshapeModes.HANDLE;
+ this._modeMap[this.mode].onMouseDown(hitProperties);
+ } else {
+ log.warn(`Unhandled hit result type: ${hitResult.type}`);
+ this.mode = ReshapeModes.FILL;
+ this._modeMap[this.mode].onMouseDown(hitProperties);
+ }
+
+ // @todo Trigger selection changed. Update styles based on selection.
+ }
+ handleMouseMove (event) {
+ const hoveredItem = getHoveredItem(event, this.getHitOptions(), true /* subselect */);
+ if ((!hoveredItem && this.prevHoveredItemId) || // There is no longer a hovered item
+ (hoveredItem && !this.prevHoveredItemId) || // There is now a hovered item
+ (hoveredItem && this.prevHoveredItemId &&
+ hoveredItem.id !== this.prevHoveredItemId)) { // hovered item changed
+ this.setHoveredItem(hoveredItem ? hoveredItem.id : null);
+ }
+ }
+ handleMouseDrag (event) {
+ if (event.event.button > 0) return; // only first mouse button
+ this._modeMap[this.mode].onMouseDrag(event);
+ }
+ handleMouseUp (event) {
+ if (event.event.button > 0) return; // only first mouse button
+ this._modeMap[this.mode].onMouseUp(event);
+ this.mode = ReshapeModes.SELECTION_BOX;
+ }
+ handleKeyUp (event) {
+ // Backspace, delete
+ if (event.key === 'delete' || event.key === 'backspace') {
+ deleteSelection(Modes.RESHAPE);
+ this.onUpdateSvg();
+ }
+ }
+ deactivateTool () {
+ paper.settings.handleSize = 0;
+ this.clearHoveredItem();
+ this.setHoveredItem = null;
+ this.clearHoveredItem = null;
+ this.onUpdateSvg = null;
+ this.lastEvent = null;
+ }
+}
+
+export default ReshapeTool;
diff --git a/src/helper/selection-tools/select-tool.js b/src/helper/selection-tools/select-tool.js
index 22320a23..bc33eea3 100644
--- a/src/helper/selection-tools/select-tool.js
+++ b/src/helper/selection-tools/select-tool.js
@@ -31,6 +31,7 @@ class SelectTool extends paper.Tool {
this.boundingBoxTool = new BoundingBoxTool(onUpdateSvg);
this.selectionBoxTool = new SelectionBoxTool(Modes.SELECT);
this.selectionBoxMode = false;
+ this.prevHoveredItemId = null;
// We have to set these functions instead of just declaring them because
// paper.js tools hook up the listeners in the setter functions.
@@ -123,6 +124,7 @@ class SelectTool extends paper.Tool {
// Backspace, delete
if (event.key === 'delete' || event.key === 'backspace') {
deleteSelection(Modes.SELECT);
+ this.clearHoveredItem();
this.boundingBoxTool.removeBoundsPath();
this.onUpdateSvg();
}
@@ -130,6 +132,11 @@ class SelectTool extends paper.Tool {
deactivateTool () {
this.clearHoveredItem();
this.boundingBoxTool.removeBoundsPath();
+ this.setHoveredItem = null;
+ this.clearHoveredItem = null;
+ this.onUpdateSvg = null;
+ this.boundingBoxTool = null;
+ this.selectionBoxTool = null;
}
}
diff --git a/src/helper/selection.js b/src/helper/selection.js
index 4abd8b31..6d318f1d 100644
--- a/src/helper/selection.js
+++ b/src/helper/selection.js
@@ -2,7 +2,7 @@ import paper from 'paper';
import Modes from '../modes/modes';
import {getItemsGroup, isGroup} from './group';
-import {getRootItem, isBoundsItem, isCompoundPathItem, isPathItem, isPGTextItem} from './item';
+import {getRootItem, isCompoundPathItem, isBoundsItem, isPathItem, isPGTextItem} from './item';
import {getItemsCompoundPath, isCompoundPath, isCompoundPathChild} from './compound-path';
/**
@@ -80,7 +80,7 @@ const setItemSelection = function (item, state, fullySelected) {
const parentGroup = getItemsGroup(item);
const itemsCompoundPath = getItemsCompoundPath(item);
- // if selection is in a group, select group not individual items
+ // if selection is in a group, select group
if (parentGroup) {
// do it recursive
setItemSelection(parentGroup, state, fullySelected);
@@ -148,8 +148,8 @@ const getSelectedItems = function (recursive) {
return itemsAndGroups;
};
-const deleteItemSelection = function () {
- const items = getSelectedItems();
+const deleteItemSelection = function (recursive) {
+ const items = getSelectedItems(recursive);
for (let i = 0; i < items.length; i++) {
items[i].remove();
}
@@ -160,11 +160,11 @@ const deleteItemSelection = function () {
// pg.undo.snapshot('deleteItemSelection');
};
-const removeSelectedSegments = function () {
+const removeSelectedSegments = function (recursive) {
// @todo add back undo
// pg.undo.snapshot('removeSelectedSegments');
- const items = getSelectedItems();
+ const items = getSelectedItems(recursive);
const segmentsToRemove = [];
for (let i = 0; i < items.length; i++) {
@@ -189,8 +189,8 @@ const removeSelectedSegments = function () {
const deleteSelection = function (mode) {
if (mode === Modes.RESHAPE) {
// If there are points selected remove them. If not delete the item selected.
- if (!removeSelectedSegments()) {
- deleteItemSelection();
+ if (!removeSelectedSegments(true /* recursive */)) {
+ deleteItemSelection(true /* recursive */);
}
} else {
deleteItemSelection();
@@ -312,8 +312,8 @@ const deleteSegmentSelection = function () {
// pg.undo.snapshot('deleteSegmentSelection');
};
-const cloneSelection = function () {
- const selectedItems = getSelectedItems();
+const cloneSelection = function (recursive) {
+ const selectedItems = getSelectedItems(recursive);
for (let i = 0; i < selectedItems.length; i++) {
const item = selectedItems[i];
item.clone();
@@ -478,7 +478,7 @@ const processRectangularSelection = function (event, rect, mode) {
* instead. (otherwise the compound path breaks because of scale-grouping)
*/
const selectRootItem = function () {
- const items = getSelectedItems();
+ const items = getSelectedItems(true /* recursive */);
for (const item of items) {
if (isCompoundPathChild(item)) {
const cp = getItemsCompoundPath(item);
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();
+ });
+});