mirror of
https://github.com/scratchfoundation/scratch-paint.git
synced 2025-01-09 14:12:13 -05:00
commit
bc8909ad9e
18 changed files with 1015 additions and 95 deletions
|
@ -1,16 +1,37 @@
|
||||||
import PropTypes from 'prop-types';
|
import bindAll from 'lodash.bindall';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PaperCanvas from '../containers/paper-canvas.jsx';
|
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 => (
|
class PaintEditorComponent extends React.Component {
|
||||||
<PaperCanvas
|
constructor (props) {
|
||||||
tool={props.tool}
|
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>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
PaintEditorComponent.propTypes = {
|
return (
|
||||||
tool: PropTypes.oneOf(Object.keys(ToolTypes)).isRequired
|
<div>
|
||||||
};
|
<PaperCanvas canvasRef={this.setCanvas} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default PaintEditorComponent;
|
export default PaintEditorComponent;
|
||||||
|
|
374
src/containers/blob/blob.js
Normal file
374
src/containers/blob/blob.js
Normal 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;
|
114
src/containers/blob/broad-brush-helper.js
Normal file
114
src/containers/blob/broad-brush-helper.js
Normal 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;
|
98
src/containers/blob/segment-brush-helper.js
Normal file
98
src/containers/blob/segment-brush-helper.js
Normal 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;
|
28
src/containers/blob/style-path.js
Normal file
28
src/containers/blob/style-path.js
Normal 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
|
||||||
|
};
|
89
src/containers/brush-mode.jsx
Normal file
89
src/containers/brush-mode.jsx
Normal 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);
|
85
src/containers/eraser-mode.jsx
Normal file
85
src/containers/eraser-mode.jsx
Normal 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);
|
|
@ -1,8 +1,8 @@
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PaintEditorComponent from '../components/paint-editor.jsx';
|
import PaintEditorComponent from '../components/paint-editor.jsx';
|
||||||
import tools from '../reducers/tools';
|
import {changeMode} from '../reducers/modes';
|
||||||
import ToolTypes from '../tools/tool-types.js';
|
import Modes from '../modes/modes';
|
||||||
import {connect} from 'react-redux';
|
import {connect} from 'react-redux';
|
||||||
|
|
||||||
class PaintEditor extends React.Component {
|
class PaintEditor extends React.Component {
|
||||||
|
@ -13,35 +13,27 @@ class PaintEditor extends React.Component {
|
||||||
document.removeEventListener('keydown', this.props.onKeyPress);
|
document.removeEventListener('keydown', this.props.onKeyPress);
|
||||||
}
|
}
|
||||||
render () {
|
render () {
|
||||||
const {
|
|
||||||
onKeyPress, // eslint-disable-line no-unused-vars
|
|
||||||
...props
|
|
||||||
} = this.props;
|
|
||||||
return (
|
return (
|
||||||
<PaintEditorComponent {...props} />
|
<PaintEditorComponent />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PaintEditor.propTypes = {
|
PaintEditor.propTypes = {
|
||||||
onKeyPress: PropTypes.func.isRequired,
|
onKeyPress: PropTypes.func.isRequired
|
||||||
tool: PropTypes.oneOf(Object.keys(ToolTypes)).isRequired
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mapStateToProps = state => ({
|
|
||||||
tool: state.tool
|
|
||||||
});
|
|
||||||
const mapDispatchToProps = dispatch => ({
|
const mapDispatchToProps = dispatch => ({
|
||||||
onKeyPress: e => {
|
onKeyPress: event => {
|
||||||
if (e.key === 'e') {
|
if (event.key === 'e') {
|
||||||
dispatch(tools.changeTool(ToolTypes.ERASER));
|
dispatch(changeMode(Modes.ERASER));
|
||||||
} else if (e.key === 'b') {
|
} else if (event.key === 'b') {
|
||||||
dispatch(tools.changeTool(ToolTypes.BRUSH));
|
dispatch(changeMode(Modes.BRUSH));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(
|
export default connect(
|
||||||
mapStateToProps,
|
null,
|
||||||
mapDispatchToProps
|
mapDispatchToProps
|
||||||
)(PaintEditor);
|
)(PaintEditor);
|
||||||
|
|
|
@ -1,9 +1,15 @@
|
||||||
|
import bindAll from 'lodash.bindall';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import paper from 'paper';
|
import paper from 'paper';
|
||||||
import ToolTypes from '../tools/tool-types.js';
|
|
||||||
|
|
||||||
class PaperCanvas extends React.Component {
|
class PaperCanvas extends React.Component {
|
||||||
|
constructor (props) {
|
||||||
|
super(props);
|
||||||
|
bindAll(this, [
|
||||||
|
'setCanvas'
|
||||||
|
]);
|
||||||
|
}
|
||||||
componentDidMount () {
|
componentDidMount () {
|
||||||
paper.setup(this.canvas);
|
paper.setup(this.canvas);
|
||||||
// Create a Paper.js Path to draw a line into it:
|
// Create a Paper.js Path to draw a line into it:
|
||||||
|
@ -19,27 +25,26 @@ class PaperCanvas extends React.Component {
|
||||||
// Draw the view now:
|
// Draw the view now:
|
||||||
paper.view.draw();
|
paper.view.draw();
|
||||||
}
|
}
|
||||||
componentWillReceiveProps (nextProps) {
|
|
||||||
if (nextProps.tool !== this.props.tool) {
|
|
||||||
// TODO switch tool
|
|
||||||
}
|
|
||||||
}
|
|
||||||
componentWillUnmount () {
|
componentWillUnmount () {
|
||||||
paper.remove();
|
paper.remove();
|
||||||
}
|
}
|
||||||
|
setCanvas (canvas) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
if (this.props.canvasRef) {
|
||||||
|
this.props.canvasRef(canvas);
|
||||||
|
}
|
||||||
|
}
|
||||||
render () {
|
render () {
|
||||||
return (
|
return (
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvas => {
|
ref={this.setCanvas}
|
||||||
this.canvas = canvas;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
PaperCanvas.propTypes = {
|
PaperCanvas.propTypes = {
|
||||||
tool: PropTypes.oneOf(Object.keys(ToolTypes)).isRequired
|
canvasRef: PropTypes.func
|
||||||
};
|
};
|
||||||
|
|
||||||
export default PaperCanvas;
|
export default PaperCanvas;
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import keyMirror from 'keymirror';
|
import keyMirror from 'keymirror';
|
||||||
|
|
||||||
const ToolTypes = keyMirror({
|
const Modes = keyMirror({
|
||||||
BRUSH: null,
|
BRUSH: null,
|
||||||
ERASER: null
|
ERASER: null
|
||||||
});
|
});
|
||||||
|
|
||||||
export default ToolTypes;
|
export default Modes;
|
31
src/reducers/brush-mode.js
Normal file
31
src/reducers/brush-mode.js
Normal 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
|
||||||
|
};
|
|
@ -1,6 +1,10 @@
|
||||||
import {combineReducers} from 'redux';
|
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({
|
export default combineReducers({
|
||||||
tool: toolReducer
|
mode: modeReducer,
|
||||||
|
brushMode: brushModeReducer,
|
||||||
|
eraserMode: eraserModeReducer
|
||||||
});
|
});
|
||||||
|
|
31
src/reducers/eraser-mode.js
Normal file
31
src/reducers/eraser-mode.js
Normal 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
32
src/reducers/modes.js
Normal 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
|
||||||
|
};
|
|
@ -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;
|
|
44
test/unit/blob-mode-reducer.test.js
Normal file
44
test/unit/blob-mode-reducer.test.js
Normal 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);
|
||||||
|
});
|
24
test/unit/modes-reducer.test.js
Normal file
24
test/unit/modes-reducer.test.js
Normal 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);
|
||||||
|
});
|
|
@ -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);
|
|
||||||
});
|
|
Loading…
Reference in a new issue