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 (