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