From f15a3dbe02131b53ea70b54b96937e922e065b8c Mon Sep 17 00:00:00 2001 From: DD Date: Thu, 21 Sep 2017 10:36:26 -0400 Subject: [PATCH] Add reshape --- src/containers/reshape-mode.jsx | 135 ++---------- src/containers/select-mode.jsx | 18 +- src/helper/bounding-box/move-tool.js | 59 ------ src/helper/hover.js | 6 +- .../bounding-box-tool.js | 2 + src/helper/selection-tools/handle-tool.js | 64 ++++++ src/helper/selection-tools/move-tool.js | 96 +++++++++ src/helper/selection-tools/point-tool.js | 193 +++++++++++++++++ src/helper/selection-tools/reshape-tool.js | 200 ++++++++++++++++++ .../rotate-tool.js | 0 .../scale-tool.js | 2 +- .../selection-tools/selection-box-tool.js | 32 +++ src/helper/selection.js | 99 +++++---- 13 files changed, 671 insertions(+), 235 deletions(-) delete mode 100644 src/helper/bounding-box/move-tool.js rename src/helper/{bounding-box => selection-tools}/bounding-box-tool.js (99%) create mode 100644 src/helper/selection-tools/handle-tool.js create mode 100644 src/helper/selection-tools/move-tool.js create mode 100644 src/helper/selection-tools/point-tool.js create mode 100644 src/helper/selection-tools/reshape-tool.js rename src/helper/{bounding-box => selection-tools}/rotate-tool.js (100%) rename src/helper/{bounding-box => selection-tools}/scale-tool.js (99%) create mode 100644 src/helper/selection-tools/selection-box-tool.js diff --git a/src/containers/reshape-mode.jsx b/src/containers/reshape-mode.jsx index 1f729841..41e18cf6 100644 --- a/src/containers/reshape-mode.jsx +++ b/src/containers/reshape-mode.jsx @@ -7,74 +7,19 @@ 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 {selectSubItems} from '../helper/selection'; +import ReshapeTool from '../helper/selection-tools/reshape-tool'; 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' + 'deactivateTool' ]); - - 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) { @@ -82,6 +27,10 @@ class ReshapeMode extends React.Component { } } componentWillReceiveProps (nextProps) { + if (this.tool && nextProps.hoveredItem !== this.props.hoveredItem) { + this.tool.setPrevHoveredItem(nextProps.hoveredItem); + } + if (nextProps.isReshapeModeActive && !this.props.isReshapeModeActive) { this.activateTool(); } else if (!nextProps.isReshapeModeActive && this.props.isReshapeModeActive) { @@ -91,72 +40,12 @@ class ReshapeMode extends React.Component { 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; - }; + selectSubItems(); + this.tool = new ReshapeTool(this.props.setHoveredItem, this.props.clearHoveredItem); + this.tool.setPrevHoveredItem(this.props.hoveredItem); this.tool.activate(); + paper.settings.handleSize = 8; } deactivateTool () { paper.settings.handleSize = 0; diff --git a/src/containers/select-mode.jsx b/src/containers/select-mode.jsx index 572f6885..1b3eaa4a 100644 --- a/src/containers/select-mode.jsx +++ b/src/containers/select-mode.jsx @@ -12,7 +12,8 @@ import {rectSelect} from '../helper/guides'; import {selectRootItem, processRectangularSelection} from '../helper/selection'; import SelectModeComponent from '../components/select-mode.jsx'; -import BoundingBoxTool from '../helper/bounding-box/bounding-box-tool'; +import BoundingBoxTool from '../helper/selection-tools/bounding-box-tool'; +import SelectionBoxTool from '../helper/selection-tools/selection-box-tool'; import paper from 'paper'; class SelectMode extends React.Component { @@ -34,8 +35,8 @@ class SelectMode extends React.Component { guide: false }; this.boundingBoxTool = new BoundingBoxTool(); + this.selectionBoxTool = new SelectionBoxTool(); this.selectionBoxMode = false; - this.selectionRect = null; } componentDidMount () { if (this.props.isSelectModeActive) { @@ -62,6 +63,7 @@ class SelectMode extends React.Component { return this._hitOptions; } activateTool () { + debugger; selectRootItem(); this.boundingBoxTool.setSelectionBounds(); this.tool = new paper.Tool(); @@ -69,17 +71,18 @@ class SelectMode extends React.Component { // Define these to sate linter const selectMode = this; - this.tool.onMouseDown = function (event) { + this.tool.onMouseDown = event => { if (event.event.button > 0) return; // only first mouse button - selectMode.props.clearHoveredItem(); - if (!selectMode.boundingBoxTool + this.props.clearHoveredItem(); + if (!this.boundingBoxTool .onMouseDown( event, event.modifiers.alt, event.modifiers.shift, - selectMode.getHitOptions(false /* preseelectedOnly */))) { - selectMode.selectionBoxMode = true; + this.getHitOptions(false /* preseelectedOnly */))) { + this.selectionBoxMode = true; + this.selectionBoxTool.onMouseDown(event.modifiers.shift); } }; @@ -125,6 +128,7 @@ class SelectMode extends React.Component { this.tool.activate(); } deactivateTool () { + debugger; this.props.clearHoveredItem(); this.boundingBoxTool.removeBoundsPath(); this.tool.remove(); diff --git a/src/helper/bounding-box/move-tool.js b/src/helper/bounding-box/move-tool.js deleted file mode 100644 index 6303d6cd..00000000 --- a/src/helper/bounding-box/move-tool.js +++ /dev/null @@ -1,59 +0,0 @@ -import {isGroup} from '../group'; -import {isCompoundPathItem, getRootItem} from '../item'; -import {snapDeltaToAngle} from '../math'; -import {clearSelection, cloneSelection, getSelectedItems, setItemSelection} from '../selection'; - -class MoveTool { - constructor () { - this.selectedItems = null; - } - - /** - * @param {!paper.HitResult} hitResult Data about the location of the mouse click - * @param {boolean} clone Whether to clone on mouse down (e.g. alt key held) - * @param {boolean} multiselect Whether to multiselect on mouse down (e.g. shift key held) - */ - onMouseDown (hitResult, clone, multiselect) { - const root = getRootItem(hitResult.item); - const item = isCompoundPathItem(root) || isGroup(root) ? root : hitResult.item; - if (!item.selected) { - // deselect all by default if multiselect isn't on - if (!multiselect) { - clearSelection(); - } - setItemSelection(item, true); - } else if (multiselect) { - setItemSelection(item, false); - } - if (clone) cloneSelection(); - this.selectedItems = getSelectedItems(); - } - onMouseDrag (event) { - const dragVector = event.point.subtract(event.downPoint); - for (const item of this.selectedItems) { - // add the position of the item before the drag started - // for later use in the snap calculation - if (!item.data.origPos) { - item.data.origPos = item.position; - } - - if (event.modifiers.shift) { - item.position = item.data.origPos.add(snapDeltaToAngle(dragVector, Math.PI / 4)); - } else { - item.position = item.data.origPos.add(dragVector); - } - } - } - onMouseUp () { - // resetting the items origin point for the next usage - for (const item of this.selectedItems) { - item.data.origPos = null; - } - this.selectedItems = null; - - // @todo add back undo - // pg.undo.snapshot('moveSelection'); - } -} - -export default MoveTool; 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/bounding-box/bounding-box-tool.js b/src/helper/selection-tools/bounding-box-tool.js similarity index 99% rename from src/helper/bounding-box/bounding-box-tool.js rename to src/helper/selection-tools/bounding-box-tool.js index 32d96918..22cb8ade 100644 --- a/src/helper/bounding-box/bounding-box-tool.js +++ b/src/helper/selection-tools/bounding-box-tool.js @@ -100,6 +100,7 @@ class BoundingBoxTool { this.setSelectionBounds(); } setSelectionBounds () { + debugger; this.removeBoundsPath(); const items = getSelectedItems(); @@ -180,6 +181,7 @@ class BoundingBoxTool { } } removeBoundsPath () { + debugger; removeHelperItems(); this.boundsPath = null; this.boundsScaleHandles.length = 0; diff --git a/src/helper/selection-tools/handle-tool.js b/src/helper/selection-tools/handle-tool.js new file mode 100644 index 00000000..b05c2e21 --- /dev/null +++ b/src/helper/selection-tools/handle-tool.js @@ -0,0 +1,64 @@ +import {clearSelection, getSelectedItems} from '../selection'; + +class HandleTool { + constructor () { + this.hitType = null; + } + /** + * @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 + } +} + +export default HandleTool; diff --git a/src/helper/selection-tools/move-tool.js b/src/helper/selection-tools/move-tool.js new file mode 100644 index 00000000..99fbb661 --- /dev/null +++ b/src/helper/selection-tools/move-tool.js @@ -0,0 +1,96 @@ +import {isGroup} from '../group'; +import {isCompoundPathItem, getRootItem} from '../item'; +import {snapDeltaToAngle} from '../math'; +import {clearSelection, cloneSelection, getSelectedItems, setItemSelection} from '../selection'; + +class MoveTool { + constructor () { + this.selectedItems = null; + } + + /** + * @param {!object} hitProperties Describes the mouse event + * @param {!paper.HitResult} hitProperties.hitResult Data about the location of the mouse click + * @param {?boolean} hitProperties.clone Whether to clone on mouse down (e.g. alt key held) + * @param {?boolean} hitProperties.multiselect Whether to multiselect on mouse down (e.g. shift key held) + * @param {?boolean} hitProperties.doubleClicked True if this is the second click in a short amout of time + * @param {?boolean} hitProperties.subselect True if we allow selection of subgroups, false if we should + * select the whole group. + */ + onMouseDown (hitProperties) { + let item = hitProperties.hitResult.item; + if (!hitProperties.subselect) { + const root = getRootItem(hitProperties.hitResult.item); + item = isCompoundPathItem(root) || isGroup(root) ? root : hitProperties.hitResult.item; + } + if (item.selected) { + // Double click causes all points to be selected in subselect mode. + if (hitProperties.doubleClicked) { + if (!hitProperties.multiselect) { + clearSelection(); + } + this._select(item, true /* state */, hitProperties.subselect, true /* fullySelect */); + } else if (hitProperties.multiselect) { + this._select(item, false /* state */, hitProperties.subselect); + } + } else { + // deselect all by default if multiselect isn't on + if (!hitProperties.multiselect) { + clearSelection(); + } + this._select(item, true, hitProperties.subselect); + } + if (hitProperties.clone) cloneSelection(hitProperties.subselect); + this.selectedItems = getSelectedItems(hitProperties.subselect); + } + /** + * Sets the selection state of an item. + * @param {!paper.Item} item Item to select or deselect + * @param {?boolean} state True if item should be selected, false if deselected + * @param {?boolean} subselect True if a subset of all points in an item are allowed to be + * selected, false if items must be selected all or nothing. + * @param {?boolean} fullySelect True if in addition to the item being selected, all of its + * control points should be selected. False if the item should be selected but not its + * points. Only relevant when subselect is true. + */ + _select (item, state, subselect, fullySelect) { + if (subselect) { + item.selected = false; + if (fullySelect) { + item.fullySelected = state; + } else { + item.selected = state; + } + } else { + setItemSelection(item, state); + } + } + onMouseDrag (event) { + const dragVector = event.point.subtract(event.downPoint); + for (const item of this.selectedItems) { + // add the position of the item before the drag started + // for later use in the snap calculation + if (!item.data.origPos) { + item.data.origPos = item.position; + } + + if (event.modifiers.shift) { + item.position = item.data.origPos.add(snapDeltaToAngle(dragVector, Math.PI / 4)); + } else { + item.position = item.data.origPos.add(dragVector); + } + } + } + onMouseUp () { + // resetting the items origin point for the next usage + for (const item of this.selectedItems) { + item.data.origPos = null; + } + this.selectedItems = null; + + // @todo add back undo + // pg.undo.snapshot('moveSelection'); + } +} + +export default MoveTool; diff --git a/src/helper/selection-tools/point-tool.js b/src/helper/selection-tools/point-tool.js new file mode 100644 index 00000000..2072dbb3 --- /dev/null +++ b/src/helper/selection-tools/point-tool.js @@ -0,0 +1,193 @@ +import paper from 'paper'; +import {snapDeltaToAngle} from '../math'; +import {clearSelection, getSelectedItems} from '../selection'; + +/** Subtool of ReshapeTool for moving control points. */ +class PointTool { + constructor () { + /** + * 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; + } + + /** + * @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 + } +} + +export default PointTool; + +// - bounding box when switching between select and reshape diff --git a/src/helper/selection-tools/reshape-tool.js b/src/helper/selection-tools/reshape-tool.js new file mode 100644 index 00000000..e5ab3cdb --- /dev/null +++ b/src/helper/selection-tools/reshape-tool.js @@ -0,0 +1,200 @@ +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 +}); + +class ReshapeTool extends paper.Tool { + static get TOLERANCE () { + return 8; + } + static get DOUBLE_CLICK_MILLIS () { + return 250; + } + constructor (setHoveredItem, clearHoveredItem) { + super(); + this.setHoveredItem = setHoveredItem; + this.clearHoveredItem = clearHoveredItem; + this.prevHoveredItem = null; + 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; + } + } + 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; + } + } + return true; + }, + segments: true, + stroke: true, + curves: true, + handles: true, + fill: true, + guide: false + }; + this.lastEvent = null; + this.mode = ReshapeModes.SELECTION_BOX; + this.selectionRect = null; + this._modeMap = {}; + this._modeMap[ReshapeModes.FILL] = new MoveTool(); + this._modeMap[ReshapeModes.POINT] = new PointTool(); + this._modeMap[ReshapeModes.HANDLE] = new HandleTool(); + this._modeMap[ReshapeModes.SELECTION_BOX] = new SelectionBoxTool(); + + // 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; + } + getHitOptions (preselectedOnly) { + this._hitOptions.tolerance = ReshapeTool.TOLERANCE / paper.view.zoom; + this._hitOptionsSelected.tolerance = ReshapeTool.TOLERANCE / paper.view.zoom; + return preselectedOnly ? this._hitOptionsSelected : this._hitOptions; + } + setPrevHoveredItem (prevHoveredItem) { + this.prevHoveredItem = prevHoveredItem; + } + 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 =========================================================== + // 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.prevHoveredItem) || // There is no longer a hovered item + (hoveredItem && !this.prevHoveredItem) || // There is now a hovered item + (hoveredItem && this.prevHoveredItem && + hoveredItem.id !== this.prevHoveredItem.id)) { // hovered item changed + this.setHoveredItem(hoveredItem); + } + } + 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); + } + } +} + +export default ReshapeTool; diff --git a/src/helper/bounding-box/rotate-tool.js b/src/helper/selection-tools/rotate-tool.js similarity index 100% rename from src/helper/bounding-box/rotate-tool.js rename to src/helper/selection-tools/rotate-tool.js diff --git a/src/helper/bounding-box/scale-tool.js b/src/helper/selection-tools/scale-tool.js similarity index 99% rename from src/helper/bounding-box/scale-tool.js rename to src/helper/selection-tools/scale-tool.js index 23cb96eb..0b7c5b6f 100644 --- a/src/helper/bounding-box/scale-tool.js +++ b/src/helper/selection-tools/scale-tool.js @@ -113,7 +113,7 @@ class ScaleTool { } } } - onMouseUp () { + onMouseUp (event) { this.pivot = null; this.origPivot = null; this.corner = null; diff --git a/src/helper/selection-tools/selection-box-tool.js b/src/helper/selection-tools/selection-box-tool.js new file mode 100644 index 00000000..ab71165e --- /dev/null +++ b/src/helper/selection-tools/selection-box-tool.js @@ -0,0 +1,32 @@ +import Modes from '../../modes/modes'; +import {rectSelect} from '../guides'; +import {clearSelection, processRectangularSelection} from '../selection'; +import {getHoveredItem} from '../hover'; + +class SelectionBoxTool { + constructor () { + this.selectionRect = null; + } + /** + * @param {boolean} multiselect Whether to multiselect on mouse down (e.g. shift key held) + */ + onMouseDown (multiselect) { + if (!multiselect) { + clearSelection(); + } + } + onMouseDrag (event) { + this.selectionRect = rectSelect(event); + // Remove this rect on the next drag and up event + this.selectionRect.removeOnDrag(); + } + onMouseUp (event) { + if (this.selectionRect) { + processRectangularSelection(event, this.selectionRect, Modes.RESHAPE); + this.selectionRect.remove(); + this.selectionRect = null; + } + } +} + +export default SelectionBoxTool; diff --git a/src/helper/selection.js b/src/helper/selection.js index 6ecba05c..cd4e5bbe 100644 --- a/src/helper/selection.js +++ b/src/helper/selection.js @@ -3,7 +3,7 @@ import Modes from '../modes/modes'; import {getAllPaperItems} from './helper'; import {getItemsGroup, isGroup} from './group'; -import {getRootItem, isBoundsItem, isCompoundPathItem, isPathItem, isPGTextItem} from './item'; +import {getRootItem, isGroupItem, isCompoundPathItem, isPathItem, isPGTextItem} from './item'; import {getItemsCompoundPath, isCompoundPath, isCompoundPathChild} from './compound-path'; const getAllSelectableItems = function () { @@ -54,12 +54,12 @@ const setItemSelection = function (item, state) { 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); } else if (itemsCompoundPath) { - setItemSelection(itemsCompoundPath, state); + setGroupSelection(itemsCompoundPath, state); } else { if (item.data && item.data.noSelect) { return; @@ -122,8 +122,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(); } @@ -134,11 +134,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++) { @@ -163,8 +163,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(); @@ -286,8 +286,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(); @@ -311,34 +311,34 @@ const getSelectedPaths = function () { return paths; }; -const checkBoundsItem = function (selectionRect, item, event) { - const itemBounds = new paper.Path([ - item.localToGlobal(item.internalBounds.topLeft), - item.localToGlobal(item.internalBounds.topRight), - item.localToGlobal(item.internalBounds.bottomRight), - item.localToGlobal(item.internalBounds.bottomLeft) - ]); - itemBounds.closed = true; - itemBounds.guide = true; +// const checkBoundsItem = function (selectionRect, item, event) { +// const itemBounds = new paper.Path([ +// item.localToGlobal(item.internalBounds.topLeft), +// item.localToGlobal(item.internalBounds.topRight), +// item.localToGlobal(item.internalBounds.bottomRight), +// item.localToGlobal(item.internalBounds.bottomLeft) +// ]); +// itemBounds.closed = true; +// itemBounds.guide = true; - for (let i = 0; i < itemBounds.segments.length; i++) { - const seg = itemBounds.segments[i]; - if (selectionRect.contains(seg.point) || - (i === 0 && selectionRect.getIntersections(itemBounds).length > 0)) { - if (event.modifiers.shift && item.selected) { - setItemSelection(item, false); +// for (let i = 0; i < itemBounds.segments.length; i++) { +// const seg = itemBounds.segments[i]; +// if (selectionRect.contains(seg.point) || +// (i === 0 && selectionRect.getIntersections(itemBounds).length > 0)) { +// if (event.modifiers.shift && item.selected) { +// setItemSelection(item, false); - } else { - setItemSelection(item, true); - } - itemBounds.remove(); - return true; +// } else { +// setItemSelection(item, true); +// } +// itemBounds.remove(); +// return true; - } - } +// } +// } - itemBounds.remove(); -}; +// itemBounds.remove(); +// }; const handleRectangularSelectionItems = function (item, event, rect, mode) { if (isPathItem(item)) { @@ -402,10 +402,10 @@ const handleRectangularSelectionItems = function (item, event, rect, mode) { } // @todo: Update toolbar state on change - } else if (isBoundsItem(item)) { - if (checkBoundsItem(rect, item, event)) { - return false; - } + // } else if (isBoundsItem(item)) { + // if (checkBoundsItem(rect, item, event)) { + // return false; + // } } return true; }; @@ -448,18 +448,30 @@ const processRectangularSelection = function (event, rect, mode) { const selectRootItem = function () { // when switching to the select tool while having a child object of a - // compound path selected, deselect the child and select the compound path - // instead. (otherwise the compound path breaks because of scale-grouping) - const items = getSelectedItems(); + // compound path or group selected, select the whole compound path or + // group instead. (otherwise the compound path breaks because of + // scale-grouping) + const items = getSelectedItems(true /* recursive */); for (const item of items) { if (isCompoundPathChild(item)) { const cp = getItemsCompoundPath(item); - setItemSelection(item, false); setItemSelection(cp, true); } + const rootItem = getRootItem(item); + if (item !== rootItem) { + setItemSelection(item, false); + setItemSelection(rootItem, true); + } } }; +const selectSubItems = function () { + // when switching to the reshape tool while having a compound path or group + // selected, deselect the group and select the children instead. + // TODO +}; + + const shouldShowIfSelection = function () { return getSelectedItems().length > 0; }; @@ -488,6 +500,7 @@ export { removeSelectedSegments, processRectangularSelection, selectRootItem, + selectSubItems, shouldShowIfSelection, shouldShowIfSelectionRecursive, shouldShowSelectAll