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(); + }); +});