diff --git a/src/components/paint-editor.jsx b/src/components/paint-editor.jsx index 4c3d025c..1459fe35 100644 --- a/src/components/paint-editor.jsx +++ b/src/components/paint-editor.jsx @@ -1,16 +1,37 @@ -import PropTypes from 'prop-types'; +import bindAll from 'lodash.bindall'; import React from 'react'; import PaperCanvas from '../containers/paper-canvas.jsx'; -import ToolTypes from '../tools/tool-types.js'; +import BrushMode from '../containers/brush-mode.jsx'; +import EraserMode from '../containers/eraser-mode.jsx'; -const PaintEditorComponent = props => ( - -); - -PaintEditorComponent.propTypes = { - tool: PropTypes.oneOf(Object.keys(ToolTypes)).isRequired -}; +class PaintEditorComponent extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'setCanvas' + ]); + this.state = {}; + } + setCanvas (canvas) { + this.setState({canvas: canvas}); + } + render () { + // Modes can't work without a canvas, so we don't render them until we have it + if (this.state.canvas) { + return ( +
+ + + +
+ ); + } + return ( +
+ +
+ ); + } +} export default PaintEditorComponent; diff --git a/src/containers/blob/blob.js b/src/containers/blob/blob.js new file mode 100644 index 00000000..b06199b8 --- /dev/null +++ b/src/containers/blob/blob.js @@ -0,0 +1,374 @@ +import paper from 'paper'; +import log from '../../log/log'; +import BroadBrushHelper from './broad-brush-helper'; +import SegmentBrushHelper from './segment-brush-helper'; +import {styleCursorPreview} from './style-path'; + +/** + * Shared code for the brush and eraser mode. Adds functions on the paper tool object + * to handle mouse events, which are delegated to broad-brush-helper and segment-brush-helper + * based on the brushSize in the state. + */ +class Blobbiness { + static get BROAD () { + return 'broadbrush'; + } + static get SEGMENT () { + return 'segmentbrush'; + } + + // If brush size >= threshold use segment brush, else use broadbrush + // Segment brush has performance issues at low threshold, but broad brush has weird corners + // which get more obvious the bigger it is + static get THRESHOLD () { + return 9; + } + + constructor () { + this.broadBrushHelper = new BroadBrushHelper(); + this.segmentBrushHelper = new SegmentBrushHelper(); + } + + /** + * Set configuration options for a blob + * @param {!object} options Configuration + * @param {!number} options.brushSize Width of blob marking made by mouse + * @param {!boolean} options.isEraser Whether the stroke should be treated as an erase path. If false, + * the stroke is an additive path. + */ + setOptions (options) { + this.options = options; + this.resizeCursorIfNeeded(); + } + + /** + * Adds handlers on the mouse tool to draw blobs. Initialize with configuration options for a blob. + * @param {!object} options Configuration + * @param {!number} options.brushSize Width of blob marking made by mouse + * @param {!boolean} options.isEraser Whether the stroke should be treated as an erase path. If false, + * the stroke is an additive path. + */ + activateTool (options) { + this.tool = new paper.Tool(); + this.cursorPreviewLastPoint = new paper.Point(-10000, -10000); + this.setOptions(options); + this.tool.fixedDistance = 1; + + const blob = this; + this.tool.onMouseMove = function (event) { + blob.resizeCursorIfNeeded(event.point); + styleCursorPreview(blob.cursorPreview, blob.options.isEraser); + blob.cursorPreview.bringToFront(); + blob.cursorPreview.position = event.point; + }; + + this.tool.onMouseDown = function (event) { + blob.resizeCursorIfNeeded(event.point); + if (event.event.button > 0) return; // only first mouse button + + if (blob.options.brushSize < Blobbiness.THRESHOLD) { + blob.brush = Blobbiness.BROAD; + blob.broadBrushHelper.onBroadMouseDown(event, blob.tool, blob.options); + } else { + blob.brush = Blobbiness.SEGMENT; + blob.segmentBrushHelper.onSegmentMouseDown(event, blob.tool, blob.options); + } + blob.cursorPreview.bringToFront(); + blob.cursorPreview.position = event.point; + paper.view.draw(); + }; + + this.tool.onMouseDrag = function (event) { + blob.resizeCursorIfNeeded(event.point); + if (event.event.button > 0) return; // only first mouse button + if (blob.brush === Blobbiness.BROAD) { + blob.broadBrushHelper.onBroadMouseDrag(event, blob.tool, blob.options); + } else if (blob.brush === Blobbiness.SEGMENT) { + blob.segmentBrushHelper.onSegmentMouseDrag(event, blob.tool, blob.options); + } else { + log.warn(`Brush type does not exist: ${blob.brush}`); + } + + blob.cursorPreview.bringToFront(); + blob.cursorPreview.position = event.point; + paper.view.draw(); + }; + + this.tool.onMouseUp = function (event) { + blob.resizeCursorIfNeeded(event.point); + if (event.event.button > 0) return; // only first mouse button + + let lastPath; + if (blob.brush === Blobbiness.BROAD) { + lastPath = blob.broadBrushHelper.onBroadMouseUp(event, blob.tool, blob.options); + } else if (blob.brush === Blobbiness.SEGMENT) { + lastPath = blob.segmentBrushHelper.onSegmentMouseUp(event, blob.tool, blob.options); + } else { + log.warn(`Brush type does not exist: ${blob.brush}`); + } + + if (blob.options.isEraser) { + blob.mergeEraser(lastPath); + } else { + blob.mergeBrush(lastPath); + } + + blob.cursorPreview.bringToFront(); + blob.cursorPreview.position = event.point; + + // Reset + blob.brush = null; + this.fixedDistance = 1; + }; + this.tool.activate(); + } + + resizeCursorIfNeeded (point) { + if (!this.options) { + return; + } + + if (typeof point === 'undefined') { + point = this.cursorPreviewLastPoint; + } else { + this.cursorPreviewLastPoint = point; + } + + if (this.cursorPreview && this.brushSize === this.options.brushSize) { + return; + } + const newPreview = new paper.Path.Circle({ + center: point, + radius: this.options.brushSize / 2 + }); + if (this.cursorPreview) { + this.cursorPreview.segments = newPreview.segments; + newPreview.remove(); + } else { + this.cursorPreview = newPreview; + styleCursorPreview(this.cursorPreview, this.options.isEraser); + } + this.brushSize = this.options.brushSize; + } + + mergeBrush (lastPath) { + const blob = this; + + // Get all path items to merge with + const paths = paper.project.getItems({ + match: function (item) { + return blob.isMergeable(lastPath, item); + } + }); + + let mergedPath = lastPath; + let i; + // Move down z order to first overlapping item + for (i = paths.length - 1; i >= 0 && !this.touches(paths[i], lastPath); i--) { + continue; + } + let mergedPathIndex = i; + for (; i >= 0; i--) { + if (!this.touches(paths[i], lastPath)) { + continue; + } + if (!paths[i].getFillColor()) { + // Ignore for merge. Paths without fill need to be in paths though, + // since they can visibly change if z order changes + } else if (this.colorMatch(paths[i], lastPath)) { + // Make sure the new shape isn't overlapped by anything that would + // visibly change if we change its z order + for (let j = mergedPathIndex; j > i; j--) { + if (this.touches(paths[j], paths[i])) { + continue; + } + } + // Merge same fill color + const tempPath = mergedPath.unite(paths[i]); + tempPath.strokeColor = paths[i].strokeColor; + tempPath.strokeWidth = paths[i].strokeWidth; + if (mergedPath === lastPath) { + tempPath.insertAbove(paths[i]); // First intersected path determines z position of the new path + } else { + tempPath.insertAbove(mergedPath); // Rest of merges join z index of merged path + mergedPathIndex--; // Removed an item, so the merged path index decreases + } + mergedPath.remove(); + mergedPath = tempPath; + paths[i].remove(); + paths.splice(i, 1); + } + } + // TODO: Add back undo + // pg.undo.snapshot('broadbrush'); + } + + mergeEraser (lastPath) { + const blob = this; + + // Get all path items to merge with + // If there are selected items, try to erase from amongst those. + let items = paper.project.getItems({ + match: function (item) { + return item.selected && blob.isMergeable(lastPath, item) && blob.touches(lastPath, item); + } + }); + // Eraser didn't hit anything selected, so assume they meant to erase from all instead of from subset + // and deselect the selection + if (items.length === 0) { + // TODO: Add back selection handling + // pg.selection.clearSelection(); + items = paper.project.getItems({ + match: function (item) { + return blob.isMergeable(lastPath, item) && blob.touches(lastPath, item); + } + }); + } + + for (let i = items.length - 1; i >= 0; i--) { + // TODO handle compound paths + if (items[i] instanceof paper.Path && (!items[i].fillColor || items[i].fillColor._alpha === 0)) { + // Gather path segments + const subpaths = []; + const firstSeg = items[i]; + const intersections = firstSeg.getIntersections(lastPath); + for (let j = intersections.length - 1; j >= 0; j--) { + const split = firstSeg.splitAt(intersections[j]); + if (split) { + split.insertAbove(firstSeg); + subpaths.push(split); + } + } + subpaths.push(firstSeg); + + // Remove the ones that are within the eraser stroke boundary + for (let k = subpaths.length - 1; k >= 0; k--) { + const segMidpoint = subpaths[k].getLocationAt(subpaths[k].length / 2).point; + if (lastPath.contains(segMidpoint)) { + subpaths[k].remove(); + subpaths.splice(k, 1); + } + } + lastPath.remove(); + // TODO add back undo + // pg.undo.snapshot('eraser'); + continue; + } + // Erase + const newPath = items[i].subtract(lastPath); + newPath.insertBelow(items[i]); + + // Gather path segments + const subpaths = []; + // TODO: Handle compound path + if (items[i] instanceof paper.Path && !items[i].closed) { + const firstSeg = items[i].clone(); + const intersections = firstSeg.getIntersections(lastPath); + // keep first and last segments + for (let j = intersections.length - 1; j >= 0; j--) { + const split = firstSeg.splitAt(intersections[j]); + split.insertAbove(firstSeg); + subpaths.push(split); + } + subpaths.push(firstSeg); + } + + // Remove the ones that are within the eraser stroke boundary, or are already part of new path. + // This way subpaths only remain if they didn't get turned into a shape by subtract. + for (let k = subpaths.length - 1; k >= 0; k--) { + const segMidpoint = subpaths[k].getLocationAt(subpaths[k].length / 2).point; + if (lastPath.contains(segMidpoint) || newPath.contains(segMidpoint)) { + subpaths[k].remove(); + subpaths.splice(k, 1); + } + } + + // Divide topologically separate shapes into their own compound paths, instead of + // everything being stuck together. + // Assume that result of erase operation returns clockwise paths for positive shapes + const clockwiseChildren = []; + const ccwChildren = []; + if (newPath.children) { + for (let j = newPath.children.length - 1; j >= 0; j--) { + const child = newPath.children[j]; + if (child.isClockwise()) { + clockwiseChildren.push(child); + } else { + ccwChildren.push(child); + } + } + for (let j = 0; j < clockwiseChildren.length; j++) { + const cw = clockwiseChildren[j]; + cw.copyAttributes(newPath); + cw.fillColor = newPath.fillColor; + cw.strokeColor = newPath.strokeColor; + cw.strokeWidth = newPath.strokeWidth; + cw.insertAbove(items[i]); + + // Go backward since we are deleting elements + let newCw = cw; + for (let k = ccwChildren.length - 1; k >= 0; k--) { + const ccw = ccwChildren[k]; + if (this.firstEnclosesSecond(ccw, cw) || this.firstEnclosesSecond(cw, ccw)) { + const temp = newCw.subtract(ccw); + temp.insertAbove(newCw); + newCw.remove(); + newCw = temp; + ccw.remove(); + ccwChildren.splice(k, 1); + } + } + } + newPath.remove(); + } + items[i].remove(); + } + lastPath.remove(); + // TODO: Add back undo handling + // pg.undo.snapshot('eraser'); + } + + colorMatch (existingPath, addedPath) { + // Note: transparent fill colors do notdetect as touching + return existingPath.getFillColor().equals(addedPath.getFillColor()) && + (addedPath.getStrokeColor() === existingPath.getStrokeColor() || // both null + (addedPath.getStrokeColor() && + addedPath.getStrokeColor().equals(existingPath.getStrokeColor()))) && + addedPath.getStrokeWidth() === existingPath.getStrokeWidth() && + this.touches(existingPath, addedPath); + } + + touches (path1, path2) { + // Two shapes are touching if their paths intersect + if (path1 && path2 && path1.intersects(path2)) { + return true; + } + return this.firstEnclosesSecond(path1, path2) || this.firstEnclosesSecond(path2, path1); + } + + firstEnclosesSecond (path1, path2) { + // Two shapes are also touching if one is completely inside the other + if (path1 && path2 && path2.firstSegment && path2.firstSegment.point && + path1.hitTest(path2.firstSegment.point)) { + return true; + } + // TODO: clean up these no point paths + return false; + } + + isMergeable (newPath, existingPath) { + return existingPath instanceof paper.PathItem && // path or compound path + existingPath !== this.cursorPreview && // don't merge with the mouse preview + existingPath !== newPath && // don't merge with self + existingPath.parent instanceof paper.Layer; // don't merge with nested in group + } + + deactivateTool () { + this.cursorPreview.remove(); + this.cursorPreview = null; + this.tool.remove(); + this.tool = null; + } +} + +export default Blobbiness; diff --git a/src/containers/blob/broad-brush-helper.js b/src/containers/blob/broad-brush-helper.js new file mode 100644 index 00000000..b3bc1ca3 --- /dev/null +++ b/src/containers/blob/broad-brush-helper.js @@ -0,0 +1,114 @@ +// Broadbrush based on http://paperjs.org/tutorials/interaction/working-with-mouse-vectors/ +import paper from 'paper'; +import {stylePath} from './style-path'; + +/** + * Broad brush functions to add as listeners on the mouse. Call them when the corresponding mouse event happens + * to get the broad brush behavior. + * + * Broad brush draws strokes by drawing points equidistant from the mouse event, perpendicular to the + * direction of motion. Shortcomings are that this path can cross itself, and 180 degree turns result + * in a flat edge. + * + * @param {!Tool} tool paper.js mouse object + */ +class BroadBrushHelper { + constructor () { + this.lastPoint = null; + this.secondLastPoint = null; + this.finalPath = null; + } + + onBroadMouseDown (event, tool, options) { + tool.minDistance = options.brushSize / 2; + tool.maxDistance = options.brushSize; + if (event.event.button > 0) return; // only first mouse button + + this.finalPath = new paper.Path(); + stylePath(this.finalPath, options.isEraser); + this.finalPath.add(event.point); + this.lastPoint = this.secondLastPoint = event.point; + } + + onBroadMouseDrag (event, tool, options) { + const step = (event.delta).normalize(options.brushSize / 2); + + // Move the first point out away from the drag so that the end of the path is rounded + if (this.finalPath.segments && this.finalPath.segments.length === 1) { + const removedPoint = this.finalPath.removeSegment(0).point; + // Add handles to round the end caps + const handleVec = step.clone(); + handleVec.length = options.brushSize / 2; + handleVec.angle += 90; + this.finalPath.add(new paper.Segment(removedPoint.subtract(step), -handleVec, handleVec)); + } + step.angle += 90; + const top = event.middlePoint.add(step); + const bottom = event.middlePoint.subtract(step); + + if (this.finalPath.segments.length > 3) { + this.finalPath.removeSegment(this.finalPath.segments.length - 1); + this.finalPath.removeSegment(0); + } + this.finalPath.add(top); + this.finalPath.add(event.point.add(step)); + this.finalPath.insert(0, bottom); + this.finalPath.insert(0, event.point.subtract(step)); + if (this.finalPath.segments.length === 5) { + // Flatten is necessary to prevent smooth from getting rid of the effect + // of the handles on the first point. + this.finalPath.flatten(Math.min(5, options.brushSize / 5)); + } + this.finalPath.smooth(); + this.lastPoint = event.point; + this.secondLastPoint = event.lastPoint; + } + + onBroadMouseUp (event, tool, options) { + // If the mouse up is at the same point as the mouse drag event then we need + // the second to last point to get the right direction vector for the end cap + if (event.point.equals(this.lastPoint)) { + this.lastPoint = this.secondLastPoint; + } + // If the points are still equal, then there was no drag, so just draw a circle. + if (event.point.equals(this.lastPoint)) { + this.finalPath.remove(); + this.finalPath = new paper.Path.Circle({ + center: event.point, + radius: options.brushSize / 2 + }); + stylePath(this.finalPath, options.isEraser); + } else { + const step = (event.point.subtract(this.lastPoint)).normalize(options.brushSize / 2); + step.angle += 90; + const handleVec = step.clone(); + handleVec.length = options.brushSize / 2; + + const top = event.point.add(step); + const bottom = event.point.subtract(step); + this.finalPath.add(top); + this.finalPath.insert(0, bottom); + + // Simplify before adding end cap so cap doesn't get warped + this.finalPath.simplify(1); + + // Add end cap + step.angle -= 90; + this.finalPath.add(new paper.Segment(event.point.add(step), handleVec, -handleVec)); + this.finalPath.closed = true; + } + + // Resolve self-crossings + const newPath = + this.finalPath + .resolveCrossings() + .reorient(true /* nonZero */, true /* clockwise */) + .reduce({simplify: true}); + newPath.copyAttributes(this.finalPath); + newPath.fillColor = this.finalPath.fillColor; + this.finalPath = newPath; + return this.finalPath; + } +} + +export default BroadBrushHelper; diff --git a/src/containers/blob/segment-brush-helper.js b/src/containers/blob/segment-brush-helper.js new file mode 100644 index 00000000..88f7debd --- /dev/null +++ b/src/containers/blob/segment-brush-helper.js @@ -0,0 +1,98 @@ +import paper from 'paper'; +import {stylePath} from './style-path'; + +/** + * Segment brush functions to add as listeners on the mouse. Call them when the corresponding mouse event happens + * to get the broad brush behavior. + * + * Segment brush draws by creating a rounded rectangle for each mouse move event and merging all of + * those shapes. Unlike the broad brush, the resulting shape will not self-intersect and when you make + * 180 degree turns, you will get a rounded point as expected. Shortcomings include that performance is + * worse, especially as the number of segments to join increase, and that there are problems in paper.js + * with union on shapes with curves, so that chunks of the union tend to disappear. + * (https://github.com/paperjs/paper.js/issues/1321) + * + * @param {!Tool} tool paper.js mouse object + */ +class SegmentBrushHelper { + constructor () { + this.lastPoint = null; + this.finalPath = null; + this.firstCircle = null; + } + + onSegmentMouseDown (event, tool, options) { + if (event.event.button > 0) return; // only first mouse button + + tool.minDistance = 1; + tool.maxDistance = options.brushSize; + + this.firstCircle = new paper.Path.Circle({ + center: event.point, + radius: options.brushSize / 2 + }); + this.finalPath = this.firstCircle; + stylePath(this.finalPath, options.isEraser); + this.lastPoint = event.point; + } + + onSegmentMouseDrag (event, tool, options) { + if (event.event.button > 0) return; // only first mouse button + + const step = (event.delta).normalize(options.brushSize / 2); + const handleVec = step.clone(); + handleVec.length = options.brushSize / 2; + handleVec.angle += 90; + + const path = new paper.Path(); + + // TODO: Add back brush styling + // path = pg.stylebar.applyActiveToolbarStyle(path); + path.fillColor = 'black'; + + // Add handles to round the end caps + path.add(new paper.Segment(this.lastPoint.subtract(step), handleVec.multiply(-1), handleVec)); + step.angle += 90; + + path.add(event.lastPoint.add(step)); + path.insert(0, event.lastPoint.subtract(step)); + path.add(event.point.add(step)); + path.insert(0, event.point.subtract(step)); + + // Add end cap + step.angle -= 90; + path.add(new paper.Segment(event.point.add(step), handleVec, handleVec.multiply(-1))); + path.closed = true; + // The unite function on curved paths does not always work (sometimes deletes half the path) + // so we have to flatten. + path.flatten(Math.min(5, options.brushSize / 5)); + + this.lastPoint = event.point; + const newPath = this.finalPath.unite(path); + path.remove(); + this.finalPath.remove(); + this.finalPath = newPath; + } + + onSegmentMouseUp (event) { + if (event.event.button > 0) return; // only first mouse button + + // TODO: This smoothing tends to cut off large portions of the path! Would like to eventually + // add back smoothing, maybe a custom implementation that only applies to a subset of the line? + + // Smooth the path. Make it unclosed first because smoothing of closed + // paths tends to cut off the path. + if (this.finalPath.segments && this.finalPath.segments.length > 4) { + this.finalPath.closed = false; + this.finalPath.simplify(2); + this.finalPath.closed = true; + // Merge again with the first point, since it gets distorted when we unclose the path. + const temp = this.finalPath.unite(this.firstCircle); + this.finalPath.remove(); + this.finalPath = temp; + } + return this.finalPath; + } +} + +export default SegmentBrushHelper; diff --git a/src/containers/blob/style-path.js b/src/containers/blob/style-path.js new file mode 100644 index 00000000..e4380869 --- /dev/null +++ b/src/containers/blob/style-path.js @@ -0,0 +1,28 @@ +const stylePath = function (path, isEraser) { + if (isEraser) { + path.fillColor = 'white'; + } else { + // TODO: Add back brush styling. Keep a separate active toolbar style for brush vs pen. + // path = pg.stylebar.applyActiveToolbarStyle(path); + path.fillColor = 'black'; + } +}; + +const styleCursorPreview = function (path, isEraser) { + if (isEraser) { + path.fillColor = 'white'; + path.strokeColor = 'cornflowerblue'; + path.strokeWidth = 1; + } else { + // TODO: Add back brush styling. Keep a separate active toolbar style for brush vs pen. + // path = pg.stylebar.applyActiveToolbarStyle(path); + path.fillColor = 'black'; + path.strokeColor = 'cornflowerblue'; + path.strokeWidth = 1; + } +}; + +export { + stylePath, + styleCursorPreview +}; diff --git a/src/containers/brush-mode.jsx b/src/containers/brush-mode.jsx new file mode 100644 index 00000000..b2549393 --- /dev/null +++ b/src/containers/brush-mode.jsx @@ -0,0 +1,89 @@ +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 Blobbiness from './blob/blob'; +import {changeBrushSize} from '../reducers/brush-mode'; + +class BrushMode extends React.Component { + static get MODE () { + return Modes.BRUSH; + } + constructor (props) { + super(props); + bindAll(this, [ + 'activateTool', + 'deactivateTool', + 'onScroll' + ]); + this.blob = new Blobbiness(); + } + componentDidMount () { + if (this.props.isBrushModeActive) { + this.activateTool(this.props); + } + } + componentWillReceiveProps (nextProps) { + if (nextProps.isBrushModeActive && !this.props.isBrushModeActive) { + this.activateTool(); + } else if (!nextProps.isBrushModeActive && this.props.isBrushModeActive) { + this.deactivateTool(); + } else if (nextProps.isBrushModeActive && this.props.isBrushModeActive) { + this.blob.setOptions({isEraser: false, ...nextProps.brushModeState}); + } + } + shouldComponentUpdate () { + return false; // Logic only component + } + activateTool () { + // TODO: Instead of clearing selection, consider a kind of "draw inside" + // analogous to how selection works with eraser + // pg.selection.clearSelection(); + + // TODO: This is temporary until a component that provides the brush size is hooked up + this.props.canvas.addEventListener('mousewheel', this.onScroll); + this.blob.activateTool({isEraser: false, ...this.props.brushModeState}); + } + deactivateTool () { + this.props.canvas.removeEventListener('mousewheel', this.onScroll); + this.blob.deactivateTool(); + } + onScroll (event) { + if (event.deltaY < 0) { + this.props.changeBrushSize(this.props.brushModeState.brushSize + 1); + } else if (event.deltaY > 0 && this.props.brushModeState.brushSize > 1) { + this.props.changeBrushSize(this.props.brushModeState.brushSize - 1); + } + return true; + } + render () { + return ( +
Brush Mode
+ ); + } +} + +BrushMode.propTypes = { + brushModeState: PropTypes.shape({ + brushSize: PropTypes.number.isRequired + }), + canvas: PropTypes.instanceOf(Element).isRequired, + changeBrushSize: PropTypes.func.isRequired, + isBrushModeActive: PropTypes.bool.isRequired +}; + +const mapStateToProps = state => ({ + brushModeState: state.brushMode, + isBrushModeActive: state.mode === BrushMode.MODE +}); +const mapDispatchToProps = dispatch => ({ + changeBrushSize: brushSize => { + dispatch(changeBrushSize(brushSize)); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(BrushMode); diff --git a/src/containers/eraser-mode.jsx b/src/containers/eraser-mode.jsx new file mode 100644 index 00000000..9ca2f6ce --- /dev/null +++ b/src/containers/eraser-mode.jsx @@ -0,0 +1,85 @@ +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 Blobbiness from './blob/blob'; +import {changeBrushSize} from '../reducers/eraser-mode'; + +class EraserMode extends React.Component { + static get MODE () { + return Modes.ERASER; + } + constructor (props) { + super(props); + bindAll(this, [ + 'activateTool', + 'deactivateTool', + 'onScroll' + ]); + this.blob = new Blobbiness(); + } + componentDidMount () { + if (this.props.isEraserModeActive) { + this.activateTool(); + } + } + componentWillReceiveProps (nextProps) { + if (nextProps.isEraserModeActive && !this.props.isEraserModeActive) { + this.activateTool(); + } else if (!nextProps.isEraserModeActive && this.props.isEraserModeActive) { + this.deactivateTool(); + } else if (nextProps.isEraserModeActive && this.props.isEraserModeActive) { + this.blob.setOptions({isEraser: true, ...nextProps.eraserModeState}); + } + } + shouldComponentUpdate () { + return false; // Logic only component + } + activateTool () { + this.props.canvas.addEventListener('mousewheel', this.onScroll); + + this.blob.activateTool({isEraser: true, ...this.props.eraserModeState}); + } + deactivateTool () { + this.props.canvas.removeEventListener('mousewheel', this.onScroll); + this.blob.deactivateTool(); + } + onScroll (event) { + event.preventDefault(); + if (event.deltaY < 0) { + this.props.changeBrushSize(this.props.eraserModeState.brushSize + 1); + } else if (event.deltaY > 0 && this.props.eraserModeState.brushSize > 1) { + this.props.changeBrushSize(this.props.eraserModeState.brushSize - 1); + } + } + render () { + return ( +
Eraser Mode
+ ); + } +} + +EraserMode.propTypes = { + canvas: PropTypes.instanceOf(Element).isRequired, + changeBrushSize: PropTypes.func.isRequired, + eraserModeState: PropTypes.shape({ + brushSize: PropTypes.number.isRequired + }), + isEraserModeActive: PropTypes.bool.isRequired +}; + +const mapStateToProps = state => ({ + eraserModeState: state.eraserMode, + isEraserModeActive: state.mode === EraserMode.MODE +}); +const mapDispatchToProps = dispatch => ({ + changeBrushSize: brushSize => { + dispatch(changeBrushSize(brushSize)); + } +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(EraserMode); diff --git a/src/containers/paint-editor.jsx b/src/containers/paint-editor.jsx index 40cc74e4..303546ba 100644 --- a/src/containers/paint-editor.jsx +++ b/src/containers/paint-editor.jsx @@ -1,8 +1,8 @@ import PropTypes from 'prop-types'; import React from 'react'; import PaintEditorComponent from '../components/paint-editor.jsx'; -import tools from '../reducers/tools'; -import ToolTypes from '../tools/tool-types.js'; +import {changeMode} from '../reducers/modes'; +import Modes from '../modes/modes'; import {connect} from 'react-redux'; class PaintEditor extends React.Component { @@ -13,35 +13,27 @@ class PaintEditor extends React.Component { document.removeEventListener('keydown', this.props.onKeyPress); } render () { - const { - onKeyPress, // eslint-disable-line no-unused-vars - ...props - } = this.props; return ( - + ); } } PaintEditor.propTypes = { - onKeyPress: PropTypes.func.isRequired, - tool: PropTypes.oneOf(Object.keys(ToolTypes)).isRequired + onKeyPress: PropTypes.func.isRequired }; -const mapStateToProps = state => ({ - tool: state.tool -}); const mapDispatchToProps = dispatch => ({ - onKeyPress: e => { - if (e.key === 'e') { - dispatch(tools.changeTool(ToolTypes.ERASER)); - } else if (e.key === 'b') { - dispatch(tools.changeTool(ToolTypes.BRUSH)); + onKeyPress: event => { + if (event.key === 'e') { + dispatch(changeMode(Modes.ERASER)); + } else if (event.key === 'b') { + dispatch(changeMode(Modes.BRUSH)); } } }); export default connect( - mapStateToProps, + null, mapDispatchToProps )(PaintEditor); diff --git a/src/containers/paper-canvas.jsx b/src/containers/paper-canvas.jsx index a3316dbf..4ccb0b58 100644 --- a/src/containers/paper-canvas.jsx +++ b/src/containers/paper-canvas.jsx @@ -1,9 +1,15 @@ +import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; import paper from 'paper'; -import ToolTypes from '../tools/tool-types.js'; class PaperCanvas extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'setCanvas' + ]); + } componentDidMount () { paper.setup(this.canvas); // Create a Paper.js Path to draw a line into it: @@ -19,27 +25,26 @@ class PaperCanvas extends React.Component { // Draw the view now: paper.view.draw(); } - componentWillReceiveProps (nextProps) { - if (nextProps.tool !== this.props.tool) { - // TODO switch tool - } - } componentWillUnmount () { paper.remove(); } + setCanvas (canvas) { + this.canvas = canvas; + if (this.props.canvasRef) { + this.props.canvasRef(canvas); + } + } render () { return ( { - this.canvas = canvas; - }} + ref={this.setCanvas} /> ); } } PaperCanvas.propTypes = { - tool: PropTypes.oneOf(Object.keys(ToolTypes)).isRequired + canvasRef: PropTypes.func }; export default PaperCanvas; diff --git a/src/tools/tool-types.js b/src/modes/modes.js similarity index 57% rename from src/tools/tool-types.js rename to src/modes/modes.js index 9a850d80..c485f0fe 100644 --- a/src/tools/tool-types.js +++ b/src/modes/modes.js @@ -1,8 +1,8 @@ import keyMirror from 'keymirror'; -const ToolTypes = keyMirror({ +const Modes = keyMirror({ BRUSH: null, ERASER: null }); -export default ToolTypes; +export default Modes; diff --git a/src/reducers/brush-mode.js b/src/reducers/brush-mode.js new file mode 100644 index 00000000..16315bef --- /dev/null +++ b/src/reducers/brush-mode.js @@ -0,0 +1,31 @@ +import log from '../log/log'; + +const CHANGE_BRUSH_SIZE = 'scratch-paint/brush-mode/CHANGE_BRUSH_SIZE'; +const initialState = {brushSize: 5}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case CHANGE_BRUSH_SIZE: + if (isNaN(action.brushSize)) { + log.warn(`Invalid brush size: ${action.brushSize}`); + return state; + } + return {brushSize: Math.max(1, action.brushSize)}; + default: + return state; + } +}; + +// Action creators ================================== +const changeBrushSize = function (brushSize) { + return { + type: CHANGE_BRUSH_SIZE, + brushSize: brushSize + }; +}; + +export { + reducer as default, + changeBrushSize +}; diff --git a/src/reducers/combine-reducers.js b/src/reducers/combine-reducers.js index 985c9b5b..31cfccc0 100644 --- a/src/reducers/combine-reducers.js +++ b/src/reducers/combine-reducers.js @@ -1,6 +1,10 @@ import {combineReducers} from 'redux'; -import toolReducer from './tools'; +import modeReducer from './modes'; +import brushModeReducer from './brush-mode'; +import eraserModeReducer from './eraser-mode'; export default combineReducers({ - tool: toolReducer + mode: modeReducer, + brushMode: brushModeReducer, + eraserMode: eraserModeReducer }); diff --git a/src/reducers/eraser-mode.js b/src/reducers/eraser-mode.js new file mode 100644 index 00000000..28b19e10 --- /dev/null +++ b/src/reducers/eraser-mode.js @@ -0,0 +1,31 @@ +import log from '../log/log'; + +const CHANGE_ERASER_SIZE = 'scratch-paint/eraser-mode/CHANGE_ERASER_SIZE'; +const initialState = {brushSize: 20}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case CHANGE_ERASER_SIZE: + if (isNaN(action.brushSize)) { + log.warn(`Invalid brush size: ${action.brushSize}`); + return state; + } + return {brushSize: Math.max(1, action.brushSize)}; + default: + return state; + } +}; + +// Action creators ================================== +const changeBrushSize = function (brushSize) { + return { + type: CHANGE_ERASER_SIZE, + brushSize: brushSize + }; +}; + +export { + reducer as default, + changeBrushSize +}; diff --git a/src/reducers/modes.js b/src/reducers/modes.js new file mode 100644 index 00000000..00f12c50 --- /dev/null +++ b/src/reducers/modes.js @@ -0,0 +1,32 @@ +import Modes from '../modes/modes'; +import log from '../log/log'; + +const CHANGE_MODE = 'scratch-paint/modes/CHANGE_MODE'; +const initialState = Modes.BRUSH; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case CHANGE_MODE: + if (action.mode in Modes) { + return action.mode; + } + log.warn(`Mode does not exist: ${action.mode}`); + /* falls through */ + default: + return state; + } +}; + +// Action creators ================================== +const changeMode = function (mode) { + return { + type: CHANGE_MODE, + mode: mode + }; +}; + +export { + reducer as default, + changeMode +}; diff --git a/src/reducers/tools.js b/src/reducers/tools.js deleted file mode 100644 index 57b7d4c7..00000000 --- a/src/reducers/tools.js +++ /dev/null @@ -1,29 +0,0 @@ -import ToolTypes from '../tools/tool-types'; -import log from '../log/log'; - -const CHANGE_TOOL = 'scratch-paint/tools/CHANGE_TOOL'; -const initialState = ToolTypes.BRUSH; - -const reducer = function (state, action) { - if (typeof state === 'undefined') state = initialState; - switch (action.type) { - case CHANGE_TOOL: - if (action.tool in ToolTypes) { - return action.tool; - } - log.warn(`Tool type does not exist: ${action.tool}`); - /* falls through */ - default: - return state; - } -}; - -// Action creators ================================== -reducer.changeTool = function (tool) { - return { - type: CHANGE_TOOL, - tool: tool - }; -}; - -export default reducer; diff --git a/test/unit/blob-mode-reducer.test.js b/test/unit/blob-mode-reducer.test.js new file mode 100644 index 00000000..845b9111 --- /dev/null +++ b/test/unit/blob-mode-reducer.test.js @@ -0,0 +1,44 @@ +/* eslint-env jest */ +import brushReducer from '../../src/reducers/brush-mode'; +import {changeBrushSize} from '../../src/reducers/brush-mode'; +import eraserReducer from '../../src/reducers/eraser-mode'; +import {changeBrushSize as changeEraserSize} from '../../src/reducers/eraser-mode'; + +test('initialState', () => { + let defaultState; + + expect(brushReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeDefined(); + expect(brushReducer(defaultState /* state */, {type: 'anything'} /* action */).brushSize).toBeGreaterThan(0); + + expect(eraserReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeTruthy(); + expect(eraserReducer(defaultState /* state */, {type: 'anything'} /* action */).brushSize).toBeGreaterThan(0); +}); + +test('changeBrushSize', () => { + let defaultState; + const newBrushSize = 8078; + + expect(brushReducer(defaultState /* state */, changeBrushSize(newBrushSize) /* action */)) + .toEqual({brushSize: newBrushSize}); + expect(brushReducer(1 /* state */, changeBrushSize(newBrushSize) /* action */)) + .toEqual({brushSize: newBrushSize}); + + expect(eraserReducer(defaultState /* state */, changeEraserSize(newBrushSize) /* action */)) + .toEqual({brushSize: newBrushSize}); + expect(eraserReducer(1 /* state */, changeEraserSize(newBrushSize) /* action */)) + .toEqual({brushSize: newBrushSize}); +}); + +test('invalidChangeBrushSize', () => { + const origState = {brushSize: 1}; + + expect(brushReducer(origState /* state */, changeBrushSize('invalid argument') /* action */)) + .toBe(origState); + expect(brushReducer(origState /* state */, changeBrushSize() /* action */)) + .toBe(origState); + + expect(eraserReducer(origState /* state */, changeEraserSize('invalid argument') /* action */)) + .toBe(origState); + expect(eraserReducer(origState /* state */, changeEraserSize() /* action */)) + .toBe(origState); +}); diff --git a/test/unit/modes-reducer.test.js b/test/unit/modes-reducer.test.js new file mode 100644 index 00000000..8f08ffc9 --- /dev/null +++ b/test/unit/modes-reducer.test.js @@ -0,0 +1,24 @@ +/* eslint-env jest */ +import Modes from '../../src/modes/modes'; +import reducer from '../../src/reducers/modes'; +import {changeMode} from '../../src/reducers/modes'; + +test('initialState', () => { + let defaultState; + expect(reducer(defaultState /* state */, {type: 'anything'} /* action */) in Modes).toBeTruthy(); +}); + +test('changeMode', () => { + let defaultState; + expect(reducer(defaultState /* state */, changeMode(Modes.ERASER) /* action */)).toBe(Modes.ERASER); + expect(reducer(Modes.ERASER /* state */, changeMode(Modes.ERASER) /* action */)) + .toBe(Modes.ERASER); + expect(reducer(Modes.BRUSH /* state */, changeMode(Modes.ERASER) /* action */)) + .toBe(Modes.ERASER); +}); + +test('invalidChangeMode', () => { + expect(reducer(Modes.BRUSH /* state */, changeMode('non-existant mode') /* action */)) + .toBe(Modes.BRUSH); + expect(reducer(Modes.BRUSH /* state */, changeMode() /* action */)).toBe(Modes.BRUSH); +}); diff --git a/test/unit/tools-reducer.test.js b/test/unit/tools-reducer.test.js deleted file mode 100644 index 428b3118..00000000 --- a/test/unit/tools-reducer.test.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-env jest */ -import ToolTypes from '../../src/tools/tool-types'; -import reducer from '../../src/reducers/tools'; - -test('initialState', () => { - let defaultState; - expect(reducer(defaultState /* state */, {type: 'anything'} /* action */) in ToolTypes).toBeTruthy(); -}); - -test('changeTool', () => { - let defaultState; - expect(reducer(defaultState /* state */, reducer.changeTool(ToolTypes.ERASER) /* action */)).toBe(ToolTypes.ERASER); - expect(reducer(ToolTypes.ERASER /* state */, reducer.changeTool(ToolTypes.ERASER) /* action */)) - .toBe(ToolTypes.ERASER); - expect(reducer(ToolTypes.BRUSH /* state */, reducer.changeTool(ToolTypes.ERASER) /* action */)) - .toBe(ToolTypes.ERASER); -}); - -test('invalidChangeTool', () => { - expect(reducer(ToolTypes.BRUSH /* state */, reducer.changeTool('non-existant tool') /* action */)) - .toBe(ToolTypes.BRUSH); - expect(reducer(ToolTypes.BRUSH /* state */, reducer.changeTool() /* action */)).toBe(ToolTypes.BRUSH); -});