Merge pull request #3 from fsih/addEraser

Add brush and eraser tools
This commit is contained in:
DD Liu 2017-08-23 10:13:15 -04:00 committed by GitHub
commit bc8909ad9e
18 changed files with 1015 additions and 95 deletions

View file

@ -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 => (
<PaperCanvas
tool={props.tool}
/>
);
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 (
<div>
<PaperCanvas canvasRef={this.setCanvas} />
<BrushMode canvas={this.state.canvas} />
<EraserMode canvas={this.state.canvas} />
</div>
);
}
return (
<div>
<PaperCanvas canvasRef={this.setCanvas} />
</div>
);
}
}
export default PaintEditorComponent;

374
src/containers/blob/blob.js Normal file
View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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
};

View file

@ -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 (
<div>Brush Mode</div>
);
}
}
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);

View file

@ -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 (
<div>Eraser Mode</div>
);
}
}
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);

View file

@ -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 (
<PaintEditorComponent {...props} />
<PaintEditorComponent />
);
}
}
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);

View file

@ -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 (
<canvas
ref={canvas => {
this.canvas = canvas;
}}
ref={this.setCanvas}
/>
);
}
}
PaperCanvas.propTypes = {
tool: PropTypes.oneOf(Object.keys(ToolTypes)).isRequired
canvasRef: PropTypes.func
};
export default PaperCanvas;

View file

@ -1,8 +1,8 @@
import keyMirror from 'keymirror';
const ToolTypes = keyMirror({
const Modes = keyMirror({
BRUSH: null,
ERASER: null
});
export default ToolTypes;
export default Modes;

View file

@ -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
};

View file

@ -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
});

View file

@ -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
};

32
src/reducers/modes.js Normal file
View file

@ -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
};

View file

@ -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;

View file

@ -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);
});

View file

@ -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);
});

View file

@ -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);
});