mirror of
https://github.com/scratchfoundation/scratch-paint.git
synced 2024-12-22 13:32:28 -05:00
commit
b06e2b5da0
11 changed files with 649 additions and 13 deletions
|
@ -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 {
|
|||
<SelectMode
|
||||
onUpdateSvg={this.props.onUpdateSvg}
|
||||
/>
|
||||
<ReshapeMode
|
||||
onUpdateSvg={this.props.onUpdateSvg}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
|
19
src/components/reshape-mode.jsx
Normal file
19
src/components/reshape-mode.jsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
|
||||
const ReshapeModeComponent = props => (
|
||||
<button onClick={props.onMouseDown}>
|
||||
<FormattedMessage
|
||||
defaultMessage="Reshape"
|
||||
description="Label for the reshape tool, which allows changing the points in the lines of the vectors"
|
||||
id="paint.reshapeMode.reshape"
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
|
||||
ReshapeModeComponent.propTypes = {
|
||||
onMouseDown: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ReshapeModeComponent;
|
|
@ -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);
|
||||
}
|
||||
|
|
86
src/containers/reshape-mode.jsx
Normal file
86
src/containers/reshape-mode.jsx
Normal file
|
@ -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 (
|
||||
<ReshapeModeComponent onMouseDown={this.props.handleMouseDown} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
|
@ -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);
|
||||
|
|
70
src/helper/selection-tools/handle-tool.js
Normal file
70
src/helper/selection-tools/handle-tool.js
Normal file
|
@ -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;
|
196
src/helper/selection-tools/point-tool.js
Normal file
196
src/helper/selection-tools/point-tool.js
Normal file
|
@ -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;
|
235
src/helper/selection-tools/reshape-tool.js
Normal file
235
src/helper/selection-tools/reshape-tool.js
Normal file
|
@ -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;
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
15
test/unit/components/reshape-mode.test.jsx
Normal file
15
test/unit/components/reshape-mode.test.jsx
Normal file
|
@ -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(
|
||||
<ReshapeModeComponent onMouseDown={onClick}/>
|
||||
);
|
||||
componentShallowWrapper.simulate('click');
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue