diff --git a/src/containers/pen-mode.jsx b/src/containers/pen-mode.jsx index ae435cf0..d971f496 100644 --- a/src/containers/pen-mode.jsx +++ b/src/containers/pen-mode.jsx @@ -5,10 +5,9 @@ import bindAll from 'lodash.bindall'; import Modes from '../modes/modes'; import {changeMode} from '../reducers/modes'; -import {clearHoveredItem, setHoveredItem} from '../reducers/hover'; -import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; +import {clearSelectedItems} from '../reducers/selected-items'; -import {getSelectedLeafItems} from '../helper/selection'; +import {clearSelection} from '../helper/selection'; import PenTool from '../helper/tools/pen-tool'; import PenModeComponent from '../components/pen-mode/pen-mode.jsx'; @@ -26,8 +25,10 @@ class PenMode extends React.Component { } } componentWillReceiveProps (nextProps) { - if (this.tool && nextProps.hoveredItemId !== this.props.hoveredItemId) { - this.tool.setPrevHoveredItemId(nextProps.hoveredItemId); + if (this.tool && + (nextProps.colorState.strokeColor !== this.props.colorState.strokeColor || + nextProps.colorState.strokeWidth !== this.props.colorState.strokeWidth)) { + this.tool.setColorState(nextProps.colorState); } if (nextProps.isPenModeActive && !this.props.isPenModeActive) { @@ -40,13 +41,12 @@ class PenMode extends React.Component { return nextProps.isPenModeActive !== this.props.isPenModeActive; } activateTool () { + clearSelection(this.props.clearSelectedItems); this.tool = new PenTool( - this.props.setHoveredItem, - this.props.clearHoveredItem, - this.props.setSelectedItems, this.props.clearSelectedItems, this.props.onUpdateSvg ); + this.tool.setColorState(this.props.colorState); this.tool.activate(); } deactivateTool () { @@ -65,33 +65,26 @@ class PenMode extends React.Component { } PenMode.propTypes = { - clearHoveredItem: PropTypes.func.isRequired, clearSelectedItems: PropTypes.func.isRequired, + colorState: PropTypes.shape({ + fillColor: PropTypes.string, + strokeColor: PropTypes.string, + strokeWidth: PropTypes.number + }).isRequired, handleMouseDown: PropTypes.func.isRequired, - hoveredItemId: PropTypes.number, isPenModeActive: PropTypes.bool.isRequired, - onUpdateSvg: PropTypes.func.isRequired, - setHoveredItem: PropTypes.func.isRequired, - setSelectedItems: PropTypes.func.isRequired + onUpdateSvg: PropTypes.func.isRequired }; const mapStateToProps = state => ({ - isPenModeActive: state.scratchPaint.mode === Modes.PEN, - hoveredItemId: state.scratchPaint.hoveredItemId + colorState: state.scratchPaint.color, + isPenModeActive: state.scratchPaint.mode === Modes.PEN + }); const mapDispatchToProps = dispatch => ({ - setHoveredItem: hoveredItemId => { - dispatch(setHoveredItem(hoveredItemId)); - }, - clearHoveredItem: () => { - dispatch(clearHoveredItem()); - }, clearSelectedItems: () => { dispatch(clearSelectedItems()); }, - setSelectedItems: () => { - dispatch(setSelectedItems(getSelectedLeafItems())); - }, handleMouseDown: () => { dispatch(changeMode(Modes.PEN)); }, diff --git a/src/helper/tools/pen-tool.js b/src/helper/tools/pen-tool.js index 1baffd89..717d371e 100644 --- a/src/helper/tools/pen-tool.js +++ b/src/helper/tools/pen-tool.js @@ -1,53 +1,174 @@ import paper from '@scratch/paper'; -import log from '../../log/log'; +import {stylePath} from '../style-path'; +import {endPointHit, touching} from '../snapping'; +import {drawHitPoint, removeHitPoint} from '../guides'; /** * Tool to handle freehand drawing of lines. */ class PenTool extends paper.Tool { + static get SNAP_TOLERANCE () { + return 5; + } + /** Smaller numbers match the line more closely, larger numbers for smoother curves */ + static get SMOOTHING () { + return 2; + } /** - * @param {function} setHoveredItem Callback to set the hovered item - * @param {function} clearHoveredItem Callback to clear the hovered item - * @param {function} setSelectedItems Callback to set the set of selected items in the Redux state * @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state * @param {!function} onUpdateSvg A callback to call when the image visibly changes */ - constructor (setHoveredItem, clearHoveredItem, setSelectedItems, clearSelectedItems, onUpdateSvg) { + constructor (clearSelectedItems, onUpdateSvg) { super(); - this.setHoveredItem = setHoveredItem; - this.clearHoveredItem = clearHoveredItem; - this.setSelectedItems = setSelectedItems; this.clearSelectedItems = clearSelectedItems; this.onUpdateSvg = onUpdateSvg; - this.prevHoveredItemId = null; - + + this.colorState = null; + this.path = null; + this.hitResult = null; + + // Piece of whole path that was added by last stroke. Used to smooth just the added part. + this.subpath = null; + this.subpathIndex = 0; + // 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.fixedDistance = 2; } - /** - * 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; + setColorState (colorState) { + this.colorState = colorState; } - handleMouseDown () { - log.warn('Pen not yet implemented'); + drawHitPoint (hitResult) { + // If near another path's endpoint, draw hit point to indicate that paths would merge + if (hitResult) { + const hitPath = hitResult.path; + if (hitResult.isFirst) { + drawHitPoint(hitPath.firstSegment.point); + } else { + drawHitPoint(hitPath.lastSegment.point); + } + } } - handleMouseMove () { + handleMouseDown (event) { + if (event.event.button > 0) return; // only first mouse button + this.subpath = new paper.Path({insert: false}); + + // If you click near a point, continue that line instead of making a new line + this.hitResult = endPointHit(event.point, PenTool.SNAP_TOLERANCE); + if (this.hitResult) { + this.path = this.hitResult.path; + stylePath(this.path, this.colorState.strokeColor, this.colorState.strokeWidth); + if (this.hitResult.isFirst) { + this.path.reverse(); + } + this.subpathIndex = this.path.segments.length; + this.path.lastSegment.handleOut = null; // Don't interfere with the curvature of the existing path + this.path.lastSegment.handleIn = null; + } + + // If not near other path, start a new path + if (!this.path) { + this.path = new paper.Path(); + stylePath(this.path, this.colorState.strokeColor, this.colorState.strokeWidth); + this.path.add(event.point); + this.subpath.add(event.point); + paper.view.draw(); + } } - handleMouseDrag () { + handleMouseMove (event) { + // If near another path's endpoint, or this path's beginpoint, clip to it to suggest + // joining/closing the paths. + if (this.hitResult) { + removeHitPoint(); + } + this.hitResult = endPointHit(event.point, PenTool.SNAP_TOLERANCE); + this.drawHitPoint(this.hitResult); } - handleMouseUp () { + handleMouseDrag (event) { + if (event.event.button > 0) return; // only first mouse button + + // If near another path's endpoint, or this path's beginpoint, highlight it to suggest + // joining/closing the paths. + if (this.hitResult) { + removeHitPoint(); + this.hitResult = null; + } + + if (this.path && + !this.path.closed && + this.path.segments.length > 3 && + touching(this.path.firstSegment.point, event.point, PenTool.SNAP_TOLERANCE)) { + this.hitResult = { + path: this.path, + segment: this.path.firstSegment, + isFirst: true + }; + } else { + this.hitResult = endPointHit(event.point, PenTool.SNAP_TOLERANCE, this.path); + } + if (this.hitResult) { + this.drawHitPoint(this.hitResult); + } + + this.path.add(event.point); + this.subpath.add(event.point); + } + handleMouseUp (event) { + if (event.event.button > 0) return; // only first mouse button + + // If I single clicked, don't do anything + if (!this.hitResult && // Might be connecting 2 points that are very close + (this.path.segments.length < 2 || + (this.path.segments.length === 2 && + touching(this.path.firstSegment.point, event.point, PenTool.SNAP_TOLERANCE)))) { + this.path.remove(); + this.path = null; + return; + } + + // Smooth only the added portion + const hasStartConnection = this.subpathIndex > 0; + const hasEndConnection = !!this.hitResult; + this.path.removeSegments(this.subpathIndex); + this.subpath.simplify(this.SMOOTHING); + if (hasStartConnection && this.subpath.length > 0) { + this.subpath.removeSegment(0); + } + if (hasEndConnection && this.subpath.length > 0) { + this.subpath.removeSegment(this.subpath.length - 1); + } + this.path.insertSegments(this.subpathIndex, this.subpath.segments); + this.subpath = null; + this.subpathIndex = 0; + + // If I intersect other line end points, join or close + if (this.hitResult) { + if (touching(this.path.firstSegment.point, this.hitResult.segment.point, PenTool.SNAP_TOLERANCE)) { + // close path + this.path.closed = true; + } else { + // joining two paths + if (!this.hitResult.isFirst) { + this.hitResult.path.reverse(); + } + this.path.join(this.hitResult.path); + } + removeHitPoint(); + this.hitResult = null; + } + + if (this.path) { + this.onUpdateSvg(); + this.path = null; + } } deactivateTool () { + this.fixedDistance = 1; } }