diff --git a/src/components/paint-editor.jsx b/src/components/paint-editor.jsx
index db93953f..3ab5fb07 100644
--- a/src/components/paint-editor.jsx
+++ b/src/components/paint-editor.jsx
@@ -1,14 +1,20 @@
import PropTypes from 'prop-types';
import React from 'react';
import PaperCanvas from '../containers/paper-canvas.jsx';
+import BrushTool from '../containers/tools/brush-tool.jsx';
const PaintEditorComponent = props => (
-
+
);
PaintEditorComponent.propTypes = {
+ canvasId: PropTypes.string.isRequired,
tool: PropTypes.shape({
name: PropTypes.string.isRequired
})
diff --git a/src/containers/paint-editor.jsx b/src/containers/paint-editor.jsx
index efe05522..c9c0bcdf 100644
--- a/src/containers/paint-editor.jsx
+++ b/src/containers/paint-editor.jsx
@@ -1,7 +1,7 @@
import PropTypes from 'prop-types';
import React from 'react';
import PaintEditorComponent from '../components/paint-editor.jsx';
-import tools from '../reducers/tools';
+import ToolsReducer from '../reducers/tools';
import ToolTypes from '../tools/tool-types.js';
import {connect} from 'react-redux';
@@ -16,6 +16,7 @@ class PaintEditor extends React.Component {
render () {
return (
);
@@ -35,9 +36,9 @@ const mapStateToProps = state => ({
const mapDispatchToProps = dispatch => ({
onKeyPress: e => {
if (e.key === 'e') {
- dispatch(tools.changeTool(ToolTypes.ERASER));
+ dispatch(ToolsReducer.changeTool(ToolTypes.ERASER));
} else if (e.key === 'b') {
- dispatch(tools.changeTool(ToolTypes.BRUSH));
+ dispatch(ToolsReducer.changeTool(ToolTypes.BRUSH));
}
}
});
diff --git a/src/containers/paper-canvas.jsx b/src/containers/paper-canvas.jsx
index 5d813a0f..5d08911d 100644
--- a/src/containers/paper-canvas.jsx
+++ b/src/containers/paper-canvas.jsx
@@ -33,12 +33,13 @@ class PaperCanvas extends React.Component {
}
render () {
return (
-
+
);
}
}
PaperCanvas.propTypes = {
+ canvasId: PropTypes.string.isRequired,
tool: PropTypes.shape({
name: PropTypes.string.isRequired
})
diff --git a/src/containers/tools/brush-tool.jsx b/src/containers/tools/brush-tool.jsx
new file mode 100644
index 00000000..604b56eb
--- /dev/null
+++ b/src/containers/tools/brush-tool.jsx
@@ -0,0 +1,102 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import {connect} from 'react-redux';
+import bindAll from 'lodash.bindall';
+import ToolTypes from '../../tools/tool-types.js';
+import BlobTool from '../../tools/blob.js';
+import BrushToolReducer from '../../reducers/brush-tool';
+import paper from 'paper';
+
+class BrushTool extends React.Component {
+ static get TOOL_TYPE () {
+ return ToolTypes.BRUSH;
+ }
+ constructor (props) {
+ super(props);
+ bindAll(this, [
+ 'activateTool',
+ 'deactivateTool',
+ 'onScroll'
+ ]);
+ this.blob = new BlobTool();
+ }
+ componentDidMount () {
+ if (this.props.tool === BrushTool.TOOL_TYPE) {
+ this.activateTool();
+ }
+ }
+ componentWillReceiveProps (nextProps) {
+ if (nextProps.tool === BrushTool.TOOL_TYPE && this.props.tool !== BrushTool.TOOL_TYPE) {
+ this.activateTool();
+ } else if (nextProps.tool !== BrushTool.TOOL_TYPE && this.props.tool === BrushTool.TOOL_TYPE) {
+ this.deactivateTool();
+ } else if (nextProps.tool === BrushTool.TOOL_TYPE && this.props.tool === BrushTool.TOOL_TYPE) {
+ this.blob.setOptions(nextProps.brushToolState);
+ }
+ }
+ shouldComponentUpdate () {
+ return false; // Logic only component
+ }
+ activateTool () {
+ document.getElementById(this.props.canvasId)
+ .addEventListener('mousewheel', this.onScroll);
+
+ const tool = new paper.Tool();
+ this.blob.activateTool(false /* isEraser */, tool, this.props.brushToolState);
+
+ // // Make sure a fill color is set on the brush
+ // if(!pg.stylebar.getFillColor()) {
+ // pg.stylebar.setFillColor(pg.stylebar.getStrokeColor());
+ // pg.stylebar.setStrokeColor(null);
+ // }
+
+ // // setup floating tool options panel in the editor
+ // pg.toolOptionPanel.setup(options, components, function() {});
+
+ tool.activate();
+ }
+ deactivateTool () {
+ document.getElementById(this.props.canvasId)
+ .removeEventListener('mousewheel', this.onScroll);
+ this.blob.deactivateTool();
+ }
+ onScroll (event) {
+ if (event.deltaY < 0) {
+ this.props.changeBrushSize(this.props.brushToolState.brushSize + 1);
+ } else if (event.deltaY > 0 && this.props.brushToolState.brushSize > 1) {
+ this.props.changeBrushSize(this.props.brushToolState.brushSize - 1);
+ }
+ return false;
+ }
+ render () {
+ return (
+
+ );
+ }
+}
+
+BrushTool.propTypes = {
+ brushToolState: PropTypes.shape({
+ brushSize: PropTypes.number.isRequired
+ }),
+ canvasId: PropTypes.string.isRequired,
+ changeBrushSize: PropTypes.func.isRequired,
+ tool: PropTypes.shape({
+ name: PropTypes.string.isRequired
+ })
+};
+
+const mapStateToProps = state => ({
+ brushToolState: state.brushTool,
+ tool: state.tool
+});
+const mapDispatchToProps = dispatch => ({
+ changeBrushSize: brushSize => {
+ dispatch(BrushToolReducer.changeBrushSize(brushSize));
+ }
+});
+
+module.exports = connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(BrushTool);
diff --git a/src/reducers/brush-tool.js b/src/reducers/brush-tool.js
new file mode 100644
index 00000000..7e59c560
--- /dev/null
+++ b/src/reducers/brush-tool.js
@@ -0,0 +1,25 @@
+const CHANGE_BRUSH_SIZE = 'scratch-paint/tools/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:
+ return {brushSize: Math.max(1, action.brushSize)};
+ default:
+ return state;
+ }
+};
+
+// Action creators ==================================
+reducer.changeBrushSize = function (brushSize) {
+ return {
+ type: CHANGE_BRUSH_SIZE,
+ brushSize: brushSize,
+ meta: {
+ throttle: 30
+ }
+ };
+};
+
+module.exports = reducer;
diff --git a/src/reducers/combine-reducers.js b/src/reducers/combine-reducers.js
index a533b763..c237d7bb 100644
--- a/src/reducers/combine-reducers.js
+++ b/src/reducers/combine-reducers.js
@@ -1,5 +1,6 @@
import {combineReducers} from 'redux';
module.exports = combineReducers({
- tool: require('./tools')
+ tool: require('./tools'),
+ brushTool: require('./brush-tool')
});
diff --git a/src/tools/blob.js b/src/tools/blob.js
new file mode 100644
index 00000000..ba5e86f5
--- /dev/null
+++ b/src/tools/blob.js
@@ -0,0 +1,327 @@
+const paper = require('paper');
+const log = require('../log/log');
+const broadBrushHelper = require('./broad-brush-helper');
+
+class BlobTool {
+
+ static get BROAD () {
+ return 'broadbrush';
+ }
+ static get SEGMENT () {
+ return 'segmentbrush';
+ }
+
+ // brush size >= threshold use segment brush, else use broadbrush
+ // Segment brush has performance issues at low threshold, but broad brush has weird corners
+ // which are more obvious the bigger it is
+ static get THRESHOLD () {
+ return 100000;
+ }
+
+ setOptions (options) {
+ console.log('setOptions');
+ this.options = options;
+ if (this.cursorPreview) {
+ this.cursorPreview = new paper.Path.Circle({
+ center: [this.cursorPreview.center.x, this.cursorPreview.center.y],
+ radius: options.brushSize / 2
+ });
+ }
+ }
+
+ activateTool (isEraser, tool, options) {
+ console.log('activateTool isEraser?'+isEraser);
+ this.tool = tool;
+ this.options = options;
+
+ let cursorPreview = this.cursorPreview = new paper.Path.Circle({
+ center: [-10000, -10000],
+ radius: options.brushSize / 2
+ });
+ this.brushSize = options.brushSize;
+
+ tool.stylePath = function (path) {
+ if (isEraser) {
+ path.fillColor = 'white';
+ if (path === cursorPreview) {
+ path.strokeColor = 'cornflowerblue';
+ path.strokeWidth = 1;
+ }
+ } else {
+ // TODO keep a separate active toolbar style for brush vs pen?
+ //path = pg.stylebar.applyActiveToolbarStyle(path);
+
+ //TODO FIX
+
+ path.fillColor = 'black';
+ if (path === cursorPreview) {
+ path.strokeColor = 'cornflowerblue';
+ path.strokeWidth = 1;
+ }
+ }
+ };
+
+ tool.stylePath(cursorPreview);
+
+ tool.fixedDistance = 1;
+
+ broadBrushHelper(tool, options);
+ // TODO add
+ //pg.segmentbrushhelper(tool, options);
+ tool.onMouseMove = function (event) {
+ if (this.brushSize !== options.brushSize) {
+ cursorPreview.remove();
+ cursorPreview = new paper.Path.Circle({
+ center: event.point,
+ radius: options.brushSize / 2
+ });
+ this.brushSize = options.brushSize;
+ }
+ tool.stylePath(cursorPreview);
+ cursorPreview.bringToFront();
+ cursorPreview.position = event.point;
+ };
+
+ tool.onMouseDown = function (event) {
+ if (event.event.button > 0) return; // only first mouse button
+
+ if (options.brushSize < BlobTool.THRESHOLD) {
+ this.brush = BlobTool.BROAD;
+ this.onBroadMouseDown(event);
+ } else {
+ this.brush = BlobTool.SEGMENT;
+ this.onSegmentMouseDown(event);
+ }
+ cursorPreview.bringToFront();
+ cursorPreview.position = event.point;
+ paper.view.draw();
+ };
+
+ tool.onMouseDrag = function (event) {
+ if (event.event.button > 0) return; // only first mouse button
+ if (this.brush === BlobTool.BROAD) {
+ this.onBroadMouseDrag(event);
+ } else if (this.brush === Blob.SEGMENT) {
+ this.onSegmentMouseDrag(event);
+ } else {
+ log.warning(`Brush type does not exist: ${this.brush}`);
+ }
+
+ cursorPreview.bringToFront();
+ cursorPreview.position = event.point;
+ paper.view.draw();
+ };
+
+ tool.onMouseUp = function (event) {
+ if (event.event.button > 0) return; // only first mouse button
+
+ let lastPath;
+ if (this.brush === BlobTool.BROAD) {
+ lastPath = this.onBroadMouseUp(event);
+ } else if (this.brush === BlobTool.SEGMENT) {
+ lastPath = this.onSegmentMouseUp(event);
+ } else {
+ log.warning(`Brush type does not exist: ${this.brush}`);
+ }
+
+ if (isEraser) {
+ tool.mergeEraser(lastPath);
+ } else {
+ tool.mergeBrush(lastPath);
+ }
+
+ cursorPreview.bringToFront();
+ cursorPreview.position = event.point;
+
+ // Reset
+ this.brush = null;
+ tool.fixedDistance = 1;
+ };
+
+ tool.mergeBrush = function (lastPath) {
+ // Get all path items to merge with
+ const paths = paper.project.getItems({
+ match: function (item) {
+ return tool.isMergeable(lastPath, item);
+ }
+ });
+
+ let mergedPath = lastPath;
+ let i;
+ // Move down z order to first overlapping item
+ for (i = paths.length - 1; i >= 0 && !tool.touches(paths[i], lastPath); i--) {
+ continue;
+ }
+ let mergedPathIndex = i;
+ for (; i >= 0; i--) {
+ if (!tool.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 (tool.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 (tool.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 FIX
+ //pg.undo.snapshot('broadbrush');
+ };
+
+ tool.mergeEraser = function (lastPath) {
+ // 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 && tool.isMergeable(lastPath, item) && tool.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 FIX
+ //pg.selection.clearSelection();
+ items = paper.project.getItems({
+ match: function (item) {
+ return tool.isMergeable(lastPath, item) && tool.touches(lastPath, item);
+ }
+ });
+ }
+
+ for (let i = items.length - 1; i >= 0; i--) {
+ // Erase
+ const newPath = items[i].subtract(lastPath);
+
+ // Gather path segments
+ const subpaths = [];
+ if (items[i] instanceof paper.PathItem && !items[i].closed) {
+ const firstSeg = items[i].clone();
+ const intersections = firstSeg.getIntersections(lastPath);
+ // keep first and last segments
+ if (intersections.length === 0) {
+ continue;
+ }
+ for (let j = intersections.length - 1; j >= 0; j--) {
+ subpaths.push(firstSeg.splitAt(intersections[j]));
+ }
+ 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 (tool.firstEnclosesSecond(ccw, cw) || tool.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 FIX
+ //pg.undo.snapshot('eraser');
+ };
+
+ tool.colorMatch = function (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() &&
+ tool.touches(existingPath, addedPath);
+ };
+
+ tool.touches = function (path1, path2) {
+ // Two shapes are touching if their paths intersect
+ if (path1 && path2 && path1.intersects(path2)) {
+ return true;
+ }
+ return tool.firstEnclosesSecond(path1, path2) || tool.firstEnclosesSecond(path2, path1);
+ };
+
+ tool.firstEnclosesSecond = function (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;
+ };
+
+ tool.isMergeable = function (newPath, existingPath) {
+ return existingPath instanceof paper.PathItem && // path or compound path
+ existingPath !== 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 () {
+ console.log('deactivateTool');
+ this.cursorPreview.remove();
+ }
+}
+
+module.exports = BlobTool;
diff --git a/src/tools/broad-brush-helper.js b/src/tools/broad-brush-helper.js
new file mode 100644
index 00000000..4d6684ca
--- /dev/null
+++ b/src/tools/broad-brush-helper.js
@@ -0,0 +1,107 @@
+// Broadbrush based on http://paperjs.org/tutorials/interaction/working-with-mouse-vectors/
+const paper = require('paper');
+
+/**
+ * Applies segment brush functions to the tool.
+ * @param {!Tool} tool paper.js mouse object
+ * @param {!options} options brush tool state object
+ * @param {!options.brushSize} brush tool diameter
+ */
+const broadBrushHelper = function (tool, options) {
+ let lastPoint;
+ let secondLastPoint;
+ let finalPath;
+
+ tool.onBroadMouseDown = function (event) {
+ tool.minDistance = options.brushSize / 4;
+ tool.maxDistance = options.brushSize;
+ if (event.event.button > 0) return; // only first mouse button
+
+ finalPath = new paper.Path();
+ tool.stylePath(finalPath);
+ finalPath.add(event.point);
+ lastPoint = secondLastPoint = event.point;
+ };
+
+ tool.onBroadMouseDrag = function (event) {
+ 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 (finalPath.segments && finalPath.segments.length === 1) {
+ const removedPoint = finalPath.removeSegment(0).point;
+ // Add handles to round the end caps
+ const handleVec = step.clone();
+ handleVec.length = options.brushSize / 2;
+ handleVec.angle += 90;
+ 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 (finalPath.segments.length > 3) {
+ finalPath.removeSegment(finalPath.segments.length - 1);
+ finalPath.removeSegment(0);
+ }
+ finalPath.add(top);
+ finalPath.add(event.point.add(step));
+ finalPath.insert(0, bottom);
+ finalPath.insert(0, event.point.subtract(step));
+ if (finalPath.segments.length === 5) {
+ // Flatten is necessary to prevent smooth from getting rid of the effect
+ // of the handles on the first point.
+ finalPath.flatten(options.brushSize / 5);
+ }
+ finalPath.smooth();
+ lastPoint = event.point;
+ secondLastPoint = event.lastPoint;
+ };
+
+ tool.onBroadMouseUp = function (event) {
+ // 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(lastPoint)) {
+ lastPoint = secondLastPoint;
+ }
+ // If the points are still equal, then there was no drag, so just draw a circle.
+ if (event.point.equals(lastPoint)) {
+ finalPath.remove();
+ finalPath = new paper.Path.Circle({
+ center: event.point,
+ radius: options.brushSize / 2
+ });
+ tool.stylePath(finalPath);
+ } else {
+ const step = (event.point.subtract(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);
+ finalPath.add(top);
+ finalPath.insert(0, bottom);
+
+ // Simplify before adding end cap so cap doesn't get warped
+ finalPath.simplify(1);
+
+ // Add end cap
+ step.angle -= 90;
+ finalPath.add(new paper.Segment(event.point.add(step), handleVec, -handleVec));
+ finalPath.closed = true;
+ }
+
+ // Resolve self-crossings
+ const newPath =
+ finalPath
+ .resolveCrossings()
+ .reorient(true /* nonZero */, true /* clockwise */)
+ .reduce({simplify: true});
+ newPath.copyAttributes(finalPath);
+ newPath.fillColor = finalPath.fillColor;
+ finalPath = newPath;
+ return finalPath;
+ };
+};
+
+module.exports = broadBrushHelper;
diff --git a/src/tools/eraser.js b/src/tools/eraser.js
new file mode 100644
index 00000000..525958b0
--- /dev/null
+++ b/src/tools/eraser.js
@@ -0,0 +1,44 @@
+// TODO share code with brush
+
+pg.tools.registerTool({
+ id: 'eraser',
+ name: 'Eraser'
+});
+
+pg.tools.eraser = function() {
+ var blob = new pg.blob();
+
+ var options = {
+ brushWidth: 20
+ };
+
+ var components = {
+ brushWidth: {
+ type: 'float',
+ label: 'Eraser width',
+ min: 0
+ }
+ };
+
+ var activateTool = function() {
+ // get options from local storage if present
+ options = pg.tools.getLocalOptions(options);
+ var tool = new Tool();
+ blob.activateTool(true /* isEraser */, tool, options);
+
+ // setup floating tool options panel in the editor
+ pg.toolOptionPanel.setup(options, components, function() {});
+
+ tool.activate();
+ };
+
+ var deactivateTool = function() {
+ blob.deactivateTool();
+ };
+
+ return {
+ options: options,
+ activateTool : activateTool,
+ deactivateTool : deactivateTool
+ };
+};
\ No newline at end of file