diff --git a/src/components/mode-tools/mode-tools.jsx b/src/components/mode-tools/mode-tools.jsx index aa1d8040..c9d4d1b4 100644 --- a/src/components/mode-tools/mode-tools.jsx +++ b/src/components/mode-tools/mode-tools.jsx @@ -12,7 +12,6 @@ import {defineMessages, injectIntl, intlShape} from 'react-intl'; import Input from '../forms/input.jsx'; import InputGroup from '../input-group/input-group.jsx'; import LabeledIconButton from '../labeled-icon-button/labeled-icon-button.jsx'; -// import LabeledIconButton from '../labeled-icon-button/labeled-icon-button.jsx'; import Modes from '../../lib/modes'; import styles from './mode-tools.css'; @@ -20,11 +19,11 @@ import copyIcon from './icons/copy.svg'; import pasteIcon from './icons/paste.svg'; import brushIcon from '../brush-mode/brush.svg'; -// import curvedPointIcon from './curved-point.svg'; +import curvedPointIcon from './icons/curved-point.svg'; import eraserIcon from '../eraser-mode/eraser.svg'; // import flipHorizontalIcon from './icons/flip-horizontal.svg'; // import flipVerticalIcon from './icons/flip-vertical.svg'; -// import straightPointIcon from './straight-point.svg'; +import straightPointIcon from './icons/straight-point.svg'; import {MAX_STROKE_WIDTH} from '../../reducers/stroke-width'; @@ -50,6 +49,16 @@ const ModeToolsComponent = props => { defaultMessage: 'Paste', description: 'Label for the paste button', id: 'paint.modeTools.paste' + }, + curved: { + defaultMessage: 'Curved', + description: 'Label for the button that converts selected points to curves', + id: 'paint.modeTools.curved' + }, + pointed: { + defaultMessage: 'Pointed', + description: 'Label for the button that converts selected points to sharp points', + id: 'paint.modeTools.pointed' } }); @@ -95,18 +104,18 @@ const ModeToolsComponent = props => { case Modes.RESHAPE: return ( <div className={classNames(props.className, styles.modeTools)}> - {/* <LabeledIconButton - imgAlt="Curved Point Icon" + <LabeledIconButton + disabled={!props.hasSelectedUncurvedPoints} imgSrc={curvedPointIcon} - title="Curved" - onClick={function () {}} + title={props.intl.formatMessage(messages.curved)} + onClick={props.onCurvePoints} /> <LabeledIconButton - imgAlt="Straight Point Icon" + disabled={!props.hasSelectedUnpointedPoints} imgSrc={straightPointIcon} - title="Pointed" - onClick={function () {}} - /> */} + title={props.intl.formatMessage(messages.pointed)} + onClick={props.onPointPoints} + /> </div> ); case Modes.SELECT: @@ -153,12 +162,16 @@ ModeToolsComponent.propTypes = { className: PropTypes.string, clipboardItems: PropTypes.arrayOf(PropTypes.array), eraserValue: PropTypes.number, + hasSelectedUncurvedPoints: PropTypes.bool, + hasSelectedUnpointedPoints: PropTypes.bool, intl: intlShape.isRequired, mode: PropTypes.string.isRequired, onBrushSliderChange: PropTypes.func, onCopyToClipboard: PropTypes.func.isRequired, + onCurvePoints: PropTypes.func.isRequired, onEraserSliderChange: PropTypes.func, onPasteFromClipboard: PropTypes.func.isRequired, + onPointPoints: PropTypes.func.isRequired, selectedItems: PropTypes.arrayOf(PropTypes.instanceOf(paper.Item)) }; diff --git a/src/containers/mode-tools.jsx b/src/containers/mode-tools.jsx index 830083af..7b06fd71 100644 --- a/src/containers/mode-tools.jsx +++ b/src/containers/mode-tools.jsx @@ -8,15 +8,132 @@ import ModeToolsComponent from '../components/mode-tools/mode-tools.jsx'; import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; import {incrementPasteOffset, setClipboardItems} from '../reducers/clipboard'; import {clearSelection, getSelectedLeafItems, getSelectedRootItems} from '../helper/selection'; +import {HANDLE_RATIO} from '../helper/math'; class ModeTools extends React.Component { constructor (props) { super(props); bindAll(this, [ + '_getSelectedUncurvedPoints', + '_getSelectedUnpointedPoints', + 'hasSelectedUncurvedPoints', + 'hasSelectedUnpointedPoints', 'handleCopyToClipboard', - 'handlePasteFromClipboard' + 'handleCurvePoints', + 'handlePasteFromClipboard', + 'handlePointPoints' ]); } + _getSelectedUncurvedPoints () { + const items = []; + const selectedItems = getSelectedLeafItems(); + for (const item of selectedItems) { + if (!item.segments) continue; + for (const seg of item.segments) { + if (seg.selected) { + const prev = seg.getPrevious(); + const next = seg.getNext(); + const isCurved = + (!prev || seg.handleIn.length > 0) && + (!next || seg.handleOut.length > 0) && + (prev && next ? seg.handleOut.isColinear(seg.handleIn) : true); + if (!isCurved) items.push(seg); + } + } + } + return items; + } + _getSelectedUnpointedPoints () { + const points = []; + const selectedItems = getSelectedLeafItems(); + for (const item of selectedItems) { + if (!item.segments) continue; + for (const seg of item.segments) { + if (seg.selected) { + if (seg.handleIn.length > 0 || seg.handleOut.length > 0) { + points.push(seg); + } + } + } + } + return points; + } + hasSelectedUncurvedPoints () { + const points = this._getSelectedUncurvedPoints(); + return points.length > 0; + } + hasSelectedUnpointedPoints () { + const points = this._getSelectedUnpointedPoints(); + return points.length > 0; + } + handleCurvePoints () { + let changed; + const points = this._getSelectedUncurvedPoints(); + for (const point of points) { + const prev = point.getPrevious(); + const next = point.getNext(); + const noHandles = point.handleIn.length === 0 && point.handleOut.length === 0; + if (!prev && !next) { + continue; + } else if (prev && next && noHandles) { + // Handles are parallel to the line from prev to next + point.handleIn = prev.point.subtract(next.point) + .normalize() + .multiply(prev.getCurve().length * HANDLE_RATIO); + } else if (prev && !next && point.handleIn.length === 0) { + // Point is end point + // Direction is average of normal at the point and direction to prev point, using the + // normal that points out from the convex side + // Lenth is curve length * HANDLE_RATIO + const convexity = prev.getCurve().getCurvatureAtTime(.5) < 0 ? -1 : 1; + point.handleIn = (prev.getCurve().getNormalAtTime(1) + .multiply(convexity) + .add(prev.point.subtract(point.point).normalize())) + .normalize() + .multiply(prev.getCurve().length * HANDLE_RATIO); + } else if (next && !prev && point.handleOut.length === 0) { + // Point is start point + // Direction is average of normal at the point and direction to prev point, using the + // normal that points out from the convex side + // Lenth is curve length * HANDLE_RATIO + const convexity = point.getCurve().getCurvatureAtTime(.5) < 0 ? -1 : 1; + point.handleOut = (point.getCurve().getNormalAtTime(0) + .multiply(convexity) + .add(next.point.subtract(point.point).normalize())) + .normalize() + .multiply(point.getCurve().length * HANDLE_RATIO); + } + + // Point guaranteed to have a handle now. Make the second handle match the length and direction of first. + // This defines a curved point. + if (point.handleIn.length > 0 && next) { + point.handleOut = point.handleIn.multiply(-1); + } else if (point.handleOut.length > 0 && prev) { + point.handleIn = point.handleOut.multiply(-1); + } + changed = true; + } + if (changed) { + this.props.setSelectedItems(); + this.props.onUpdateSvg(); + } + } + handlePointPoints () { + let changed; + const points = this._getSelectedUnpointedPoints(); + for (const point of points) { + const noHandles = point.handleIn.length === 0 && point.handleOut.length === 0; + if (!noHandles) { + point.handleIn = null; + point.handleOut = null; + changed = true; + } + } + if (changed) { + this.props.setSelectedItems(); + this.props.onUpdateSvg(); + } + } handleCopyToClipboard () { const selectedItems = getSelectedRootItems(); if (selectedItems.length > 0) { @@ -50,8 +167,12 @@ class ModeTools extends React.Component { render () { return ( <ModeToolsComponent + hasSelectedUncurvedPoints={this.hasSelectedUncurvedPoints()} + hasSelectedUnpointedPoints={this.hasSelectedUnpointedPoints()} onCopyToClipboard={this.handleCopyToClipboard} + onCurvePoints={this.handleCurvePoints} onPasteFromClipboard={this.handlePasteFromClipboard} + onPointPoints={this.handlePointPoints} /> ); } @@ -63,13 +184,17 @@ ModeTools.propTypes = { incrementPasteOffset: PropTypes.func.isRequired, onUpdateSvg: PropTypes.func.isRequired, pasteOffset: PropTypes.number, + // Listen on selected items to update hasSelectedPoints + selectedItems: + PropTypes.arrayOf(PropTypes.instanceOf(paper.Item)), // eslint-disable-line react/no-unused-prop-types setClipboardItems: PropTypes.func.isRequired, setSelectedItems: PropTypes.func.isRequired }; const mapStateToProps = state => ({ clipboardItems: state.scratchPaint.clipboard.items, - pasteOffset: state.scratchPaint.clipboard.pasteOffset + pasteOffset: state.scratchPaint.clipboard.pasteOffset, + selectedItems: state.scratchPaint.selectedItems }); const mapDispatchToProps = dispatch => ({ setClipboardItems: items => { diff --git a/src/helper/math.js b/src/helper/math.js index 5c74c740..cab99242 100644 --- a/src/helper/math.js +++ b/src/helper/math.js @@ -1,5 +1,8 @@ import paper from '@scratch/paper'; +/** The ratio of the curve length to use for the handle length to convert squares into approximately circles. */ +const HANDLE_RATIO = 0.3902628565; + const checkPointsClose = function (startPos, eventPoint, threshold) { const xOff = Math.abs(startPos.x - eventPoint.x); const yOff = Math.abs(startPos.y - eventPoint.y); @@ -95,6 +98,7 @@ const expandByOne = function (path) { }; export { + HANDLE_RATIO, checkPointsClose, expandByOne, getRandomInt, diff --git a/src/helper/selection-tools/handle-tool.js b/src/helper/selection-tools/handle-tool.js index cbf78770..305681fd 100644 --- a/src/helper/selection-tools/handle-tool.js +++ b/src/helper/selection-tools/handle-tool.js @@ -79,6 +79,7 @@ class HandleTool { } } if (moved) { + this.setSelectedItems(); this.onUpdateSvg(); } this.selectedItems = []; diff --git a/src/helper/selection-tools/point-tool.js b/src/helper/selection-tools/point-tool.js index 0c88d2f9..9a9ddd4b 100644 --- a/src/helper/selection-tools/point-tool.js +++ b/src/helper/selection-tools/point-tool.js @@ -1,6 +1,7 @@ import paper from '@scratch/paper'; import {snapDeltaToAngle} from '../math'; import {clearSelection, getSelectedLeafItems} from '../selection'; +import {HANDLE_RATIO} from '../math'; /** Subtool of ReshapeTool for moving control points. */ class PointTool { @@ -72,8 +73,8 @@ class PointTool { 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); + let handleIn = hitProperties.hitResult.location.tangent.multiply(-beforeCurveLength * HANDLE_RATIO); + let handleOut = hitProperties.hitResult.location.tangent.multiply(afterCurveLength * HANDLE_RATIO); // 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); @@ -98,7 +99,7 @@ class PointTool { if (beforeSegment && beforeSegment.handleOut) { if (afterSegment) { beforeSegment.handleOut = - beforeSegment.handleOut.multiply(beforeCurveLength / 2 / beforeSegment.handleOut.length); + beforeSegment.handleOut.multiply(beforeCurveLength * HANDLE_RATIO / beforeSegment.handleOut.length); } else { beforeSegment.handleOut = null; } @@ -106,7 +107,7 @@ class PointTool { if (afterSegment && afterSegment.handleIn) { if (beforeSegment) { afterSegment.handleIn = - afterSegment.handleIn.multiply(afterCurveLength / 2 / afterSegment.handleIn.length); + afterSegment.handleIn.multiply(afterCurveLength * HANDLE_RATIO / afterSegment.handleIn.length); } else { afterSegment.handleIn = null; } @@ -123,14 +124,15 @@ class PointTool { if (beforeSegment && beforeSegment.handleOut) { if (afterSegment) { beforeSegment.handleOut = - beforeSegment.handleOut.multiply(curveLength / 2 / beforeSegment.handleOut.length); + beforeSegment.handleOut.multiply(curveLength * HANDLE_RATIO / beforeSegment.handleOut.length); } else { beforeSegment.handleOut = null; } } if (afterSegment && afterSegment.handleIn) { if (beforeSegment) { - afterSegment.handleIn = afterSegment.handleIn.multiply(curveLength / 2 / afterSegment.handleIn.length); + afterSegment.handleIn = + afterSegment.handleIn.multiply(curveLength * HANDLE_RATIO / afterSegment.handleIn.length); } else { afterSegment.handleIn = null; } diff --git a/src/reducers/selected-items.js b/src/reducers/selected-items.js index 67a2d147..c68dece7 100644 --- a/src/reducers/selected-items.js +++ b/src/reducers/selected-items.js @@ -10,17 +10,11 @@ const reducer = function (state, action) { log.warn(`No selected items or wrong format provided: ${action.selectedItems}`); return state; } - // If they are not equal, return the new list of items. Else return old list - if (action.selectedItems.length !== state.length) { - return action.selectedItems; + // If they are both empty, no change + if (action.selectedItems.length === 0 && state.length === 0) { + return state; } - // Shallow equality check (we may need to update this later for more granularity) - for (let i = 0; i < action.selectedItems.length; i++) { - if (action.selectedItems[i] !== state[i]) { - return action.selectedItems; - } - } - return state; + return action.selectedItems; default: return state; }