diff --git a/src/containers/line-mode.jsx b/src/containers/line-mode.jsx index 8ba4de6c..e9d5c592 100644 --- a/src/containers/line-mode.jsx +++ b/src/containers/line-mode.jsx @@ -4,11 +4,12 @@ import React from 'react'; import {connect} from 'react-redux'; import bindAll from 'lodash.bindall'; import Modes from '../modes/modes'; -import {clearSelection, getSelectedLeafItems} from '../helper/selection'; -import {MIXED} from '../helper/style-path'; +import {clearSelection} from '../helper/selection'; +import {endPointHit, touching} from '../helper/snapping'; +import {drawHitPoint, removeHitPoint} from '../helper/guides'; +import {stylePath} from '../helper/style-path'; import {changeMode} from '../reducers/modes'; -import {changeStrokeWidth} from '../reducers/stroke-width'; -import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; +import {clearSelectedItems} from '../reducers/selected-items'; import LineModeComponent from '../components/line-mode/line-mode.jsx'; @@ -21,13 +22,11 @@ class LineMode extends React.Component { bindAll(this, [ 'activateTool', 'deactivateTool', + 'drawHitPoint', 'onMouseDown', 'onMouseMove', 'onMouseDrag', - 'onMouseUp', - 'toleranceSquared', - 'findLineEnd', - 'onScroll' + 'onMouseUp' ]); } componentDidMount () { @@ -47,7 +46,6 @@ class LineMode extends React.Component { } activateTool () { clearSelection(this.props.clearSelectedItems); - this.props.canvas.addEventListener('mousewheel', this.onScroll); this.tool = new paper.Tool(); this.path = null; @@ -73,197 +71,135 @@ class LineMode extends React.Component { this.tool.activate(); } onMouseDown (event) { - // Deselect old path - if (this.path) { - this.path.setSelected(false); - this.path = null; - } + if (event.event.button > 0) return; // only first mouse button // If you click near a point, continue that line instead of making a new line - this.hitResult = this.findLineEnd(event.point); + this.hitResult = endPointHit(event.point, LineMode.SNAP_TOLERANCE); if (this.hitResult) { this.path = this.hitResult.path; + stylePath(this.path, this.props.colorState.strokeColor, this.props.colorState.strokeWidth); if (this.hitResult.isFirst) { this.path.reverse(); } - this.path.lastSegment.setSelected(true); - this.path.add(this.hitResult.segment); // Add second point, which is what will move when dragged - this.path.lastSegment.handleOut = null; // Make sure line isn't curvy - this.path.lastSegment.handleIn = null; + + this.path.lastSegment.handleOut = null; // Make sure added line isn't made curvy + this.path.add(this.hitResult.segment.point); // Add second point, which is what will move when dragged } // If not near other path, start a new path if (!this.path) { this.path = new paper.Path(); - - this.path.setStrokeColor( - this.props.colorState.strokeColor === MIXED ? 'black' : this.props.colorState.strokeColor); - // Make sure a visible line is drawn - this.path.setStrokeWidth( - this.props.colorState.strokeWidth === null || this.props.colorState.strokeWidth === 0 ? - 1 : this.props.colorState.strokeWidth); + stylePath(this.path, this.props.colorState.strokeColor, this.props.colorState.strokeWidth); - this.path.setSelected(true); this.path.add(event.point); this.path.add(event.point); // Add second point, which is what will move when dragged paper.view.draw(); } } + 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); + } + } + } onMouseMove (event) { + if (this.hitResult) { + removeHitPoint(); + } + this.hitResult = endPointHit(event.point, LineMode.SNAP_TOLERANCE); + this.drawHitPoint(this.hitResult); + } + onMouseDrag (event) { + if (event.event.button > 0) return; // only first mouse button + // If near another path's endpoint, or this path's beginpoint, clip to it to suggest // joining/closing the paths. if (this.hitResult) { - this.hitResult.path.setSelected(false); + removeHitPoint(); this.hitResult = null; } if (this.path && !this.path.closed && - this.path.firstSegment.point.getDistance(event.point, true) < this.toleranceSquared()) { - this.hitResult = { - path: this.path, - segment: this.path.firstSegment, - isFirst: true - }; - } else { - this.hitResult = this.findLineEnd(event.point); - } - - if (this.hitResult) { - const hitPath = this.hitResult.path; - hitPath.setSelected(true); - if (this.hitResult.isFirst) { - hitPath.firstSegment.setSelected(true); - } else { - hitPath.lastSegment.setSelected(true); - } - } - } - onMouseDrag (event) { - // If near another path's endpoint, or this path's beginpoint, clip to it to suggest - // joining/closing the paths. - if (this.hitResult && this.hitResult.path !== this.path) this.hitResult.path.setSelected(false); - this.hitResult = null; - - if (this.path && this.path.segments.length > 3 && - this.path.firstSegment.point.getDistance(event.point, true) < this.toleranceSquared()) { + touching(this.path.firstSegment.point, event.point, LineMode.SNAP_TOLERANCE)) { this.hitResult = { path: this.path, segment: this.path.firstSegment, isFirst: true }; } else { - this.hitResult = this.findLineEnd(event.point, this.path); - if (this.hitResult) { - const hitPath = this.hitResult.path; - hitPath.setSelected(true); - if (this.hitResult.isFirst) { - hitPath.firstSegment.setSelected(true); - } else { - hitPath.lastSegment.setSelected(true); - } - } + this.hitResult = endPointHit(event.point, LineMode.SNAP_TOLERANCE, this.path); } // snapping - if (this.path) { - if (this.hitResult) { - this.path.lastSegment.point = this.hitResult.segment.point; - } else { - this.path.lastSegment.point = event.point; - } + if (this.hitResult) { + this.drawHitPoint(this.hitResult); + this.path.lastSegment.point = this.hitResult.segment.point; + } else { + this.path.lastSegment.point = event.point; } } onMouseUp (event) { + if (event.event.button > 0) return; // only first mouse button + // If I single clicked, don't do anything if (this.path.segments.length < 2 || (this.path.segments.length === 2 && - this.path.firstSegment.point.getDistance(event.point, true) < this.toleranceSquared())) { + touching(this.path.firstSegment.point, event.point, LineMode.SNAP_TOLERANCE) && + !this.hitResult)) { // Let lines be short if you're connecting them this.path.remove(); this.path = null; - // TODO don't erase the line if both ends are snapped to different points return; - } else if ( - this.path.lastSegment.point.getDistance(this.path.segments[this.path.segments.length - 2].point, true) < - this.toleranceSquared()) { + } else if (!this.hitResult && + touching(this.path.lastSegment.point, this.path.segments[this.path.segments.length - 2].point, + LineMode.SNAP_TOLERANCE)) { + // Single click or short drag on an existing path end point this.path.removeSegment(this.path.segments.length - 1); + this.path = null; return; } - // If I intersect other line end points, join or close if (this.hitResult) { this.path.removeSegment(this.path.segments.length - 1); - if (this.path.firstSegment === this.hitResult.segment) { + if (this.path.firstSegment.point.equals(this.hitResult.segment.point)) { + this.path.firstSegment.handleIn = null; // Make sure added line isn't made curvy // close path this.path.closed = true; - this.path.setSelected(false); } else { // joining two paths if (!this.hitResult.isFirst) { this.hitResult.path.reverse(); } + this.hitResult.path.firstSegment.handleIn = null; // Make sure added line isn't made curvy this.path.join(this.hitResult.path); } + removeHitPoint(); this.hitResult = null; } - this.props.setSelectedItems(); if (this.path) { this.props.onUpdateSvg(); + this.path = null; } } - toleranceSquared () { - return Math.pow(LineMode.SNAP_TOLERANCE / paper.view.zoom, 2); - } - findLineEnd (point, excludePath) { - const lines = paper.project.getItems({ - class: paper.Path - }); - // Prefer more recent lines - for (let i = lines.length - 1; i >= 0; i--) { - if (lines[i].closed) { - continue; - } - if (excludePath && lines[i] === excludePath) { - continue; - } - if (lines[i].firstSegment && - lines[i].firstSegment.point.getDistance(point, true) < this.toleranceSquared()) { - return { - path: lines[i], - segment: lines[i].firstSegment, - isFirst: true - }; - } - if (lines[i].lastSegment && lines[i].lastSegment.point.getDistance(point, true) < this.toleranceSquared()) { - return { - path: lines[i], - segment: lines[i].lastSegment, - isFirst: false - }; - } - } - return null; - } deactivateTool () { this.props.canvas.removeEventListener('mousewheel', this.onScroll); this.tool.remove(); this.tool = null; - this.hitResult = null; + if (this.hitResult) { + removeHitPoint(); + this.hitResult = null; + } if (this.path) { - this.path.setSelected(false); this.path = null; } } - onScroll (event) { - if (event.deltaY < 0) { - this.props.changeStrokeWidth(this.props.colorState.strokeWidth + 1); - } else if (event.deltaY > 0 && this.props.colorState.strokeWidth > 1) { - this.props.changeStrokeWidth(this.props.colorState.strokeWidth - 1); - } - return true; - } render () { return ( ({ @@ -294,15 +228,9 @@ const mapStateToProps = state => ({ isLineModeActive: state.scratchPaint.mode === Modes.LINE }); const mapDispatchToProps = dispatch => ({ - changeStrokeWidth: strokeWidth => { - dispatch(changeStrokeWidth(strokeWidth)); - }, clearSelectedItems: () => { dispatch(clearSelectedItems()); }, - setSelectedItems: () => { - dispatch(setSelectedItems(getSelectedLeafItems())); - }, handleMouseDown: () => { dispatch(changeMode(Modes.LINE)); } diff --git a/src/helper/blob-tools/broad-brush-helper.js b/src/helper/blob-tools/broad-brush-helper.js index 40170fca..0b2247a5 100644 --- a/src/helper/blob-tools/broad-brush-helper.js +++ b/src/helper/blob-tools/broad-brush-helper.js @@ -1,6 +1,6 @@ // Broadbrush based on http://paperjs.org/tutorials/interaction/working-with-mouse-vectors/ import paper from '@scratch/paper'; -import {stylePath} from '../../helper/style-path'; +import {styleBlob} from '../../helper/style-path'; /** * Broad brush functions to add as listeners on the mouse. Call them when the corresponding mouse event happens @@ -25,7 +25,7 @@ class BroadBrushHelper { if (event.event.button > 0) return; // only first mouse button this.finalPath = new paper.Path(); - stylePath(this.finalPath, options); + styleBlob(this.finalPath, options); this.finalPath.add(event.point); this.lastPoint = this.secondLastPoint = event.point; } @@ -77,7 +77,7 @@ class BroadBrushHelper { center: event.point, radius: options.brushSize / 2 }); - stylePath(this.finalPath, options); + styleBlob(this.finalPath, options); } else { const step = (event.point.subtract(this.lastPoint)).normalize(options.brushSize / 2); step.angle += 90; diff --git a/src/helper/blob-tools/segment-brush-helper.js b/src/helper/blob-tools/segment-brush-helper.js index 3ab38466..78992a8d 100644 --- a/src/helper/blob-tools/segment-brush-helper.js +++ b/src/helper/blob-tools/segment-brush-helper.js @@ -1,5 +1,5 @@ import paper from '@scratch/paper'; -import {stylePath} from '../../helper/style-path'; +import {styleBlob} from '../../helper/style-path'; /** * Segment brush functions to add as listeners on the mouse. Call them when the corresponding mouse event happens @@ -32,7 +32,7 @@ class SegmentBrushHelper { radius: options.brushSize / 2 }); this.finalPath = this.firstCircle; - stylePath(this.finalPath, options); + styleBlob(this.finalPath, options); this.lastPoint = event.point; } @@ -46,7 +46,7 @@ class SegmentBrushHelper { const path = new paper.Path(); - stylePath(path, options); + styleBlob(path, options); // Add handles to round the end caps path.add(new paper.Segment(this.lastPoint.subtract(step), handleVec.multiply(-1), handleVec)); diff --git a/src/helper/guides.js b/src/helper/guides.js index eea83520..54f0cc3a 100644 --- a/src/helper/guides.js +++ b/src/helper/guides.js @@ -58,12 +58,8 @@ const rectSelect = function (event, color) { return rect; }; -const getGuideColor = function (colorName) { - if (colorName === 'blue') { - return GUIDE_BLUE; - } else if (colorName === 'grey') { - return GUIDE_GREY; - } +const getGuideColor = function () { + return GUIDE_BLUE; }; const _removePaperItemsByDataTags = function (tags) { @@ -96,12 +92,30 @@ const removeAllGuides = function () { _removePaperItemsByTags(['guide']); }; +const removeHitPoint = function () { + _removePaperItemsByDataTags(['isHitPoint']); +}; + +const drawHitPoint = function (point) { + removeHitPoint(); + if (point) { + const hitPoint = paper.Path.Circle(point, 4 /* radius */); + hitPoint.strokeColor = GUIDE_BLUE; + hitPoint.fillColor = new paper.Color(1, 1, 1, 0.5); + hitPoint.parent = getGuideLayer(); + hitPoint.data.isHitPoint = true; + hitPoint.data.isHelperItem = true; + } +}; + export { hoverItem, hoverBounds, rectSelect, removeAllGuides, removeHelperItems, + drawHitPoint, + removeHitPoint, getGuideColor, setDefaultGuideStyle }; diff --git a/src/helper/selection-tools/bounding-box-tool.js b/src/helper/selection-tools/bounding-box-tool.js index cd44a7e3..54145d0b 100644 --- a/src/helper/selection-tools/bounding-box-tool.js +++ b/src/helper/selection-tools/bounding-box-tool.js @@ -170,7 +170,7 @@ class BoundingBoxTool { noSelect: true, noHover: true }; - rotHandle.fillColor = getGuideColor('blue'); + rotHandle.fillColor = getGuideColor(); rotHandle.parent = getGuideLayer(); this.boundsRotHandles[index] = rotHandle; } @@ -186,7 +186,7 @@ class BoundingBoxTool { noHover: true }, size: [size / paper.view.zoom, size / paper.view.zoom], - fillColor: getGuideColor('blue'), + fillColor: getGuideColor(), parent: getGuideLayer() }); } diff --git a/src/helper/snapping.js b/src/helper/snapping.js new file mode 100644 index 00000000..595bab6a --- /dev/null +++ b/src/helper/snapping.js @@ -0,0 +1,58 @@ +import paper from '@scratch/paper'; + +/** + * @param {paper.Point} point1 point 1 + * @param {paper.Point} point2 point 2 + * @param {number} tolerance Distance allowed between points that are "touching" + * @return {boolean} true if points are within the tolerance distance. + */ +const touching = function (point1, point2, tolerance) { + return point1.getDistance(point2, true) < Math.pow(tolerance / paper.view.zoom, 2); +}; + +/** + * @param {!paper.Point} point Point to check line endpoint hits against + * @param {!number} tolerance Distance within which it counts as a hit + * @param {?paper.Path} excludePath Path to exclude from hit test, if any. For instance, you + * are drawing a line and don't want it to snap to its own start point. + * @return {object} data about the end point of an unclosed path, if any such point is within the + * tolerance distance of the given point, or null if none exists. + */ +const endPointHit = function (point, tolerance, excludePath) { + const lines = paper.project.getItems({ + class: paper.Path + }); + // Prefer more recent lines + for (let i = lines.length - 1; i >= 0; i--) { + if (lines[i].closed) { + continue; + } + if (!(lines[i].parent instanceof paper.Layer)) { + // Don't connect to lines inside of groups + continue; + } + if (excludePath && lines[i] === excludePath) { + continue; + } + if (lines[i].firstSegment && touching(lines[i].firstSegment.point, point, tolerance)) { + return { + path: lines[i], + segment: lines[i].firstSegment, + isFirst: true + }; + } + if (lines[i].lastSegment && touching(lines[i].lastSegment.point, point, tolerance)) { + return { + path: lines[i], + segment: lines[i].lastSegment, + isFirst: false + }; + } + } + return null; +}; + +export { + endPointHit, + touching +}; diff --git a/src/helper/style-path.js b/src/helper/style-path.js index 9711f178..7fea6770 100644 --- a/src/helper/style-path.js +++ b/src/helper/style-path.js @@ -198,7 +198,7 @@ const getColorsFromSelection = function (selectedItems) { }; }; -const stylePath = function (path, options) { +const styleBlob = function (path, options) { if (options.isEraser) { path.fillColor = 'white'; } else if (options.fillColor) { @@ -209,6 +209,14 @@ const stylePath = function (path, options) { } }; +const stylePath = function (path, strokeColor, strokeWidth) { + // Make sure a visible line is drawn + path.setStrokeColor( + (strokeColor === MIXED || strokeColor === null) ? 'black' : strokeColor); + path.setStrokeWidth( + strokeWidth === null || strokeWidth === 0 ? 1 : strokeWidth); +}; + const styleCursorPreview = function (path, options) { if (options.isEraser) { path.fillColor = 'white'; @@ -228,6 +236,7 @@ export { applyStrokeWidthToSelection, getColorsFromSelection, MIXED, + styleBlob, stylePath, styleCursorPreview };