Merge branch 'develop' into structure

This commit is contained in:
DD 2017-09-06 15:25:49 -04:00
commit 09dad3df7d
18 changed files with 552 additions and 44 deletions

View file

@ -0,0 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
import {FormattedMessage} from 'react-intl';
const LineModeComponent = props => (
<button onClick={props.onMouseDown}>
<FormattedMessage
defaultMessage="Line"
description="Label for the line tool, which draws straight line segments"
id="paint.lineMode.line"
/>
</button>
);
LineModeComponent.propTypes = {
onMouseDown: PropTypes.func.isRequired
};
export default LineModeComponent;

View file

@ -3,6 +3,8 @@ import React from 'react';
import PaperCanvas from '../containers/paper-canvas.jsx'; import PaperCanvas from '../containers/paper-canvas.jsx';
import BrushMode from '../containers/brush-mode.jsx'; import BrushMode from '../containers/brush-mode.jsx';
import EraserMode from '../containers/eraser-mode.jsx'; import EraserMode from '../containers/eraser-mode.jsx';
import PropTypes from 'prop-types';
import LineMode from '../containers/line-mode.jsx';
import styles from './paint-editor.css'; import styles from './paint-editor.css';
@ -103,14 +105,29 @@ class PaintEditorComponent extends React.Component {
{/* Modes */} {/* Modes */}
{this.state.canvas ? ( {this.state.canvas ? (
<div className={styles.modeSelector}> <div className={styles.modeSelector}>
<BrushMode canvas={this.state.canvas} /> <BrushMode
<EraserMode canvas={this.state.canvas} /> canvas={this.state.canvas}
onUpdateSvg={this.props.onUpdateSvg}
/>
<EraserMode
canvas={this.state.canvas}
onUpdateSvg={this.props.onUpdateSvg}
/>
<LineMode
canvas={this.state.canvas}
onUpdateSvg={this.props.onUpdateSvg}
/>
</div> </div>
) : null} ) : null}
{/* Canvas */} {/* Canvas */}
<div className={styles.canvasContainer}> <div className={styles.canvasContainer}>
<PaperCanvas canvasRef={this.setCanvas} /> <PaperCanvas
canvasRef={this.setCanvas}
rotationCenterX={this.props.rotationCenterX}
rotationCenterY={this.props.rotationCenterY}
svg={this.props.svg}
/>
</div> </div>
</div> </div>
</div> </div>
@ -118,4 +135,11 @@ class PaintEditorComponent extends React.Component {
} }
} }
PaintEditorComponent.propTypes = {
onUpdateSvg: PropTypes.func.isRequired,
rotationCenterX: PropTypes.number,
rotationCenterY: PropTypes.number,
svg: PropTypes.string
};
export default PaintEditorComponent; export default PaintEditorComponent;

View file

@ -24,9 +24,13 @@ class Blobbiness {
return 9; return 9;
} }
constructor () { /**
* @param {function} updateCallback call when the drawing has changed to let listeners know
*/
constructor (updateCallback) {
this.broadBrushHelper = new BroadBrushHelper(); this.broadBrushHelper = new BroadBrushHelper();
this.segmentBrushHelper = new SegmentBrushHelper(); this.segmentBrushHelper = new SegmentBrushHelper();
this.updateCallback = updateCallback;
} }
/** /**
@ -113,6 +117,9 @@ class Blobbiness {
blob.mergeBrush(lastPath); blob.mergeBrush(lastPath);
} }
blob.cursorPreview.visible = false;
blob.updateCallback();
blob.cursorPreview.visible = true;
blob.cursorPreview.bringToFront(); blob.cursorPreview.bringToFront();
blob.cursorPreview.position = event.point; blob.cursorPreview.position = event.point;

View file

@ -16,7 +16,7 @@ class BrushMode extends React.Component {
'deactivateTool', 'deactivateTool',
'onScroll' 'onScroll'
]); ]);
this.blob = new Blobbiness(); this.blob = new Blobbiness(this.props.onUpdateSvg);
} }
componentDidMount () { componentDidMount () {
if (this.props.isBrushModeActive) { if (this.props.isBrushModeActive) {
@ -70,7 +70,8 @@ BrushMode.propTypes = {
canvas: PropTypes.instanceOf(Element).isRequired, canvas: PropTypes.instanceOf(Element).isRequired,
changeBrushSize: PropTypes.func.isRequired, changeBrushSize: PropTypes.func.isRequired,
handleMouseDown: PropTypes.func.isRequired, handleMouseDown: PropTypes.func.isRequired,
isBrushModeActive: PropTypes.bool.isRequired isBrushModeActive: PropTypes.bool.isRequired,
onUpdateSvg: PropTypes.func.isRequired
}; };
const mapStateToProps = state => ({ const mapStateToProps = state => ({

View file

@ -16,7 +16,7 @@ class EraserMode extends React.Component {
'deactivateTool', 'deactivateTool',
'onScroll' 'onScroll'
]); ]);
this.blob = new Blobbiness(); this.blob = new Blobbiness(this.props.onUpdateSvg);
} }
componentDidMount () { componentDidMount () {
if (this.props.isEraserModeActive) { if (this.props.isEraserModeActive) {
@ -66,7 +66,8 @@ EraserMode.propTypes = {
brushSize: PropTypes.number.isRequired brushSize: PropTypes.number.isRequired
}), }),
handleMouseDown: PropTypes.func.isRequired, handleMouseDown: PropTypes.func.isRequired,
isEraserModeActive: PropTypes.bool.isRequired isEraserModeActive: PropTypes.bool.isRequired,
onUpdateSvg: PropTypes.func.isRequired
}; };
const mapStateToProps = state => ({ const mapStateToProps = state => ({

View file

@ -0,0 +1,304 @@
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 {changeLineWidth} from '../reducers/line-mode';
import LineModeComponent from '../components/line-mode.jsx';
import {changeMode} from '../reducers/modes';
import paper from 'paper';
class LineMode extends React.Component {
static get SNAP_TOLERANCE () {
return 6;
}
constructor (props) {
super(props);
bindAll(this, [
'activateTool',
'deactivateTool',
'onMouseDown',
'onMouseMove',
'onMouseDrag',
'onMouseUp',
'toleranceSquared',
'findLineEnd',
'onScroll'
]);
}
componentDidMount () {
if (this.props.isLineModeActive) {
this.activateTool();
}
}
componentWillReceiveProps (nextProps) {
if (nextProps.isLineModeActive && !this.props.isLineModeActive) {
this.activateTool();
} else if (!nextProps.isLineModeActive && this.props.isLineModeActive) {
this.deactivateTool();
}
}
shouldComponentUpdate () {
return false; // Static component, for now
}
activateTool () {
// TODO add back selection
// pg.selection.clearSelection();
this.props.canvas.addEventListener('mousewheel', this.onScroll);
this.tool = new paper.Tool();
this.path = null;
this.hitResult = null;
// TODO add back colors
// Make sure a stroke color is set on the line tool
// if(!pg.stylebar.getStrokeColor()) {
// pg.stylebar.setStrokeColor(pg.stylebar.getFillColor());
// pg.stylebar.setFillColor(null);
// }
const lineMode = this;
this.tool.onMouseDown = function (event) {
if (event.event.button > 0) return; // only first mouse button
lineMode.onMouseDown(event);
};
this.tool.onMouseMove = function (event) {
lineMode.onMouseMove(event);
};
this.tool.onMouseDrag = function (event) {
if (event.event.button > 0) return; // only first mouse button
lineMode.onMouseDrag(event);
};
this.tool.onMouseUp = function (event) {
if (event.event.button > 0) return; // only first mouse button
lineMode.onMouseUp(event);
};
this.tool.activate();
}
onMouseDown (event) {
// Deselect old path
if (this.path) {
this.path.setSelected(false);
this.path = null;
}
// If you click near a point, continue that line instead of making a new line
this.hitResult = this.findLineEnd(event.point);
if (this.hitResult) {
this.path = this.hitResult.path;
if (this.hitResult.isFirst) {
this.path.reverse();
}
this.path.lastSegment.setSelected(true);
this.path.add(this.hitResult.segment); // Add second point, which is what will move when dragged
this.path.lastSegment.handleOut = null; // Make sure line isn't curvy
this.path.lastSegment.handleIn = null;
}
// If not near other path, start a new path
if (!this.path) {
this.path = new paper.Path();
// TODO add back style
// this.path = pg.stylebar.applyActiveToolbarStyle(path);
this.path.setStrokeColor('black');
this.path.setStrokeWidth(this.props.lineModeState.lineWidth);
this.path.setSelected(true);
this.path.add(event.point);
this.path.add(event.point); // Add second point, which is what will move when dragged
paper.view.draw();
}
}
onMouseMove (event) {
// If near another path's endpoint, or this path's beginpoint, clip to it to suggest
// joining/closing the paths.
if (this.hitResult) {
this.hitResult.path.setSelected(false);
this.hitResult = null;
}
if (this.path &&
!this.path.closed &&
this.path.firstSegment.point.getDistance(event.point, true) < this.toleranceSquared()) {
this.hitResult = {
path: this.path,
segment: this.path.firstSegment,
isFirst: true
};
} else {
this.hitResult = this.findLineEnd(event.point);
}
if (this.hitResult) {
const hitPath = this.hitResult.path;
hitPath.setSelected(true);
if (this.hitResult.isFirst) {
hitPath.firstSegment.setSelected(true);
} else {
hitPath.lastSegment.setSelected(true);
}
}
}
onMouseDrag (event) {
// If near another path's endpoint, or this path's beginpoint, clip to it to suggest
// joining/closing the paths.
if (this.hitResult && this.hitResult.path !== this.path) this.hitResult.path.setSelected(false);
this.hitResult = null;
if (this.path &&
this.path.segments.length > 3 &&
this.path.firstSegment.point.getDistance(event.point, true) < this.toleranceSquared()) {
this.hitResult = {
path: this.path,
segment: this.path.firstSegment,
isFirst: true
};
} else {
this.hitResult = this.findLineEnd(event.point, this.path);
if (this.hitResult) {
const hitPath = this.hitResult.path;
hitPath.setSelected(true);
if (this.hitResult.isFirst) {
hitPath.firstSegment.setSelected(true);
} else {
hitPath.lastSegment.setSelected(true);
}
}
}
// snapping
if (this.path) {
if (this.hitResult) {
this.path.lastSegment.point = this.hitResult.segment.point;
} else {
this.path.lastSegment.point = event.point;
}
}
}
onMouseUp (event) {
// If I single clicked, don't do anything
if (this.path.segments.length < 2 ||
(this.path.segments.length === 2 &&
this.path.firstSegment.point.getDistance(event.point, true) < this.toleranceSquared())) {
this.path.remove();
this.path = null;
// TODO don't erase the line if both ends are snapped to different points
return;
} else if (
this.path.lastSegment.point.getDistance(this.path.segments[this.path.segments.length - 2].point, true) <
this.toleranceSquared()) {
this.path.removeSegment(this.path.segments.length - 1);
return;
}
// If I intersect other line end points, join or close
if (this.hitResult) {
this.path.removeSegment(this.path.segments.length - 1);
if (this.path.firstSegment === this.hitResult.segment) {
// close path
this.path.closed = true;
this.path.setSelected(false);
} else {
// joining two paths
if (!this.hitResult.isFirst) {
this.hitResult.path.reverse();
}
this.path.join(this.hitResult.path);
}
this.hitResult = null;
}
this.props.onUpdateSvg();
// TODO add back undo
// if (this.path) {
// pg.undo.snapshot('line');
// }
}
toleranceSquared () {
return Math.pow(LineMode.SNAP_TOLERANCE / paper.view.zoom, 2);
}
findLineEnd (point, excludePath) {
const lines = paper.project.getItems({
class: paper.Path
});
// Prefer more recent lines
for (let i = lines.length - 1; i >= 0; i--) {
if (lines[i].closed) {
continue;
}
if (excludePath && lines[i] === excludePath) {
continue;
}
if (lines[i].firstSegment &&
lines[i].firstSegment.point.getDistance(point, true) < this.toleranceSquared()) {
return {
path: lines[i],
segment: lines[i].firstSegment,
isFirst: true
};
}
if (lines[i].lastSegment && lines[i].lastSegment.point.getDistance(point, true) < this.toleranceSquared()) {
return {
path: lines[i],
segment: lines[i].lastSegment,
isFirst: false
};
}
}
return null;
}
deactivateTool () {
this.props.canvas.removeEventListener('mousewheel', this.onScroll);
this.tool.remove();
this.tool = null;
this.hitResult = null;
if (this.path) {
this.path.setSelected(false);
this.path = null;
}
}
onScroll (event) {
if (event.deltaY < 0) {
this.props.changeLineWidth(this.props.lineModeState.lineWidth + 1);
} else if (event.deltaY > 0 && this.props.lineModeState.lineWidth > 1) {
this.props.changeLineWidth(this.props.lineModeState.lineWidth - 1);
}
return true;
}
render () {
return (
<LineModeComponent onMouseDown={this.props.handleMouseDown} />
);
}
}
LineMode.propTypes = {
canvas: PropTypes.instanceOf(Element).isRequired,
changeLineWidth: PropTypes.func.isRequired,
handleMouseDown: PropTypes.func.isRequired,
isLineModeActive: PropTypes.bool.isRequired,
lineModeState: PropTypes.shape({
lineWidth: PropTypes.number.isRequired
}),
onUpdateSvg: PropTypes.func.isRequired
};
const mapStateToProps = state => ({
lineModeState: state.scratchPaint.lineMode,
isLineModeActive: state.scratchPaint.mode === Modes.LINE
});
const mapDispatchToProps = dispatch => ({
changeLineWidth: lineWidth => {
dispatch(changeLineWidth(lineWidth));
},
handleMouseDown: () => {
dispatch(changeMode(Modes.LINE));
}
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(LineMode);

View file

@ -4,23 +4,50 @@ import PaintEditorComponent from '../components/paint-editor.jsx';
import {changeMode} from '../reducers/modes'; import {changeMode} from '../reducers/modes';
import Modes from '../modes/modes'; import Modes from '../modes/modes';
import {connect} from 'react-redux'; import {connect} from 'react-redux';
import bindAll from 'lodash.bindall';
import paper from 'paper';
class PaintEditor extends React.Component { class PaintEditor extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'handleUpdateSvg'
]);
}
componentDidMount () { componentDidMount () {
document.addEventListener('keydown', this.props.onKeyPress); document.addEventListener('keydown', this.props.onKeyPress);
} }
componentWillUnmount () { componentWillUnmount () {
document.removeEventListener('keydown', this.props.onKeyPress); document.removeEventListener('keydown', this.props.onKeyPress);
} }
handleUpdateSvg () {
const bounds = paper.project.activeLayer.bounds;
this.props.onUpdateSvg(
paper.project.exportSVG({
asString: true,
matrix: new paper.Matrix().translate(-bounds.x, -bounds.y)
}),
paper.project.view.center.x - bounds.x,
paper.project.view.center.y - bounds.y);
}
render () { render () {
return ( return (
<PaintEditorComponent /> <PaintEditorComponent
rotationCenterX={this.props.rotationCenterX}
rotationCenterY={this.props.rotationCenterY}
svg={this.props.svg}
onUpdateSvg={this.handleUpdateSvg}
/>
); );
} }
} }
PaintEditor.propTypes = { PaintEditor.propTypes = {
onKeyPress: PropTypes.func.isRequired onKeyPress: PropTypes.func.isRequired,
onUpdateSvg: PropTypes.func.isRequired,
rotationCenterX: PropTypes.number,
rotationCenterY: PropTypes.number,
svg: PropTypes.string
}; };
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
@ -29,6 +56,8 @@ const mapDispatchToProps = dispatch => ({
dispatch(changeMode(Modes.ERASER)); dispatch(changeMode(Modes.ERASER));
} else if (event.key === 'b') { } else if (event.key === 'b') {
dispatch(changeMode(Modes.BRUSH)); dispatch(changeMode(Modes.BRUSH));
} else if (event.key === 'l') {
dispatch(changeMode(Modes.LINE));
} }
} }
}); });

View file

@ -7,27 +7,50 @@ class PaperCanvas extends React.Component {
constructor (props) { constructor (props) {
super(props); super(props);
bindAll(this, [ bindAll(this, [
'setCanvas' 'setCanvas',
'importSvg'
]); ]);
} }
componentDidMount () { componentDidMount () {
paper.setup(this.canvas); paper.setup(this.canvas);
// Create a Paper.js Path to draw a line into it: if (this.props.svg) {
const path = new paper.Path(); this.importSvg(this.props.svg, this.props.rotationCenterX, this.props.rotationCenterY);
// Give the stroke a color }
path.strokeColor = 'black'; }
const start = new paper.Point(100, 100); componentWillReceiveProps (newProps) {
// Move to start and draw a line from there paper.project.activeLayer.removeChildren();
path.moveTo(start); this.importSvg(newProps.svg, newProps.rotationCenterX, newProps.rotationCenterY);
// Note that the plus operator on Point objects does not work
// in JavaScript. Instead, we need to call the add() function:
path.lineTo(start.add([200, -50]));
// Draw the view now:
paper.view.draw();
} }
componentWillUnmount () { componentWillUnmount () {
paper.remove(); paper.remove();
} }
importSvg (svg, rotationCenterX, rotationCenterY) {
const imported = paper.project.importSVG(svg,
{
expandShapes: true,
onLoad: function (item) {
// Remove viewbox
if (item.clipped) {
item.clipped = false;
// Consider removing clip mask here?
}
while (item.reduce() !== item) {
item = item.reduce();
}
}
});
if (typeof rotationCenterX !== 'undefined' && typeof rotationCenterY !== 'undefined') {
imported.position =
paper.project.view.center
.add(imported.bounds.width / 2, imported.bounds.height / 2)
.subtract(rotationCenterX, rotationCenterY);
} else {
// Center
imported.position = paper.project.view.center;
}
paper.project.view.update();
}
setCanvas (canvas) { setCanvas (canvas) {
this.canvas = canvas; this.canvas = canvas;
if (this.props.canvasRef) { if (this.props.canvasRef) {
@ -44,7 +67,10 @@ class PaperCanvas extends React.Component {
} }
PaperCanvas.propTypes = { PaperCanvas.propTypes = {
canvasRef: PropTypes.func canvasRef: PropTypes.func,
rotationCenterX: PropTypes.number,
rotationCenterY: PropTypes.number,
svg: PropTypes.string
}; };
export default PaperCanvas; export default PaperCanvas;

View file

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

View file

@ -13,10 +13,28 @@ const store = createStore(
intlInitialState, intlInitialState,
window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__() window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__()
); );
const svgString =
'<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"' +
' x="0px" y="0px" width="32px" height="32px" viewBox="0.5 384.5 32 32"' +
' enable-background="new 0.5 384.5 32 32" xml:space="preserve">' +
'<path fill="none" stroke="#000000" stroke-width="3" stroke-miterlimit="10" d="M7.5,392.241h7.269' +
'c4.571,0,8.231,5.555,8.231,10.123v7.377"/>' +
'<polyline points="10.689,399.492 3.193,391.997 10.689,384.5 "/>' +
'<polyline points="30.185,405.995 22.689,413.491 15.192,405.995 "/>' +
'</svg>';
const onUpdateSvg = function (newSvgString, rotationCenterX, rotationCenterY) {
console.log(newSvgString);
console.log(`rotationCenterX: ${rotationCenterX} rotationCenterY: ${rotationCenterY}`);
};
ReactDOM.render(( ReactDOM.render((
<Provider store={store}> <Provider store={store}>
<IntlProvider> <IntlProvider>
<PaintEditor /> <PaintEditor
rotationCenterX={0}
rotationCenterY={0}
svg={svgString}
onUpdateSvg={onUpdateSvg}
/>
</IntlProvider> </IntlProvider>
</Provider> </Provider>
), appTarget); ), appTarget);

31
src/reducers/line-mode.js Normal file
View file

@ -0,0 +1,31 @@
import log from '../log/log';
const CHANGE_LINE_WIDTH = 'scratch-paint/line-mode/CHANGE_LINE_WIDTH';
const initialState = {lineWidth: 2};
const reducer = function (state, action) {
if (typeof state === 'undefined') state = initialState;
switch (action.type) {
case CHANGE_LINE_WIDTH:
if (isNaN(action.lineWidth)) {
log.warn(`Invalid line width: ${action.lineWidth}`);
return state;
}
return {lineWidth: Math.max(1, action.lineWidth)};
default:
return state;
}
};
// Action creators ==================================
const changeLineWidth = function (lineWidth) {
return {
type: CHANGE_LINE_WIDTH,
lineWidth: lineWidth
};
};
export {
reducer as default,
changeLineWidth
};

View file

@ -2,9 +2,11 @@ import {combineReducers} from 'redux';
import modeReducer from './modes'; import modeReducer from './modes';
import brushModeReducer from './brush-mode'; import brushModeReducer from './brush-mode';
import eraserModeReducer from './eraser-mode'; import eraserModeReducer from './eraser-mode';
import lineModeReducer from './line-mode';
export default combineReducers({ export default combineReducers({
mode: modeReducer, mode: modeReducer,
brushMode: brushModeReducer, brushMode: brushModeReducer,
eraserMode: eraserModeReducer eraserMode: eraserModeReducer,
lineMode: lineModeReducer
}); });

View file

@ -0,0 +1,15 @@
/* eslint-env jest */
import React from 'react'; // eslint-disable-line no-unused-vars
import {shallow} from 'enzyme';
import LineModeComponent from '../../../src/components/line-mode.jsx'; // eslint-disable-line no-unused-vars
describe('LineModeComponent', () => {
test('triggers callback when clicked', () => {
const onClick = jest.fn();
const componentShallowWrapper = shallow(
<LineModeComponent onMouseDown={onClick}/>
);
componentShallowWrapper.simulate('click');
expect(onClick).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,31 @@
/* eslint-env jest */
import lineReducer from '../../src/reducers/line-mode';
import {changeLineWidth} from '../../src/reducers/line-mode';
test('initialState', () => {
let defaultState;
expect(lineReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeDefined();
expect(lineReducer(defaultState /* state */, {type: 'anything'} /* action */).lineWidth).toBeGreaterThan(0);
});
test('changeLineWidth', () => {
let defaultState;
const newLineWidth = 8078;
expect(lineReducer(defaultState /* state */, changeLineWidth(newLineWidth) /* action */))
.toEqual({lineWidth: newLineWidth});
expect(lineReducer(2 /* state */, changeLineWidth(newLineWidth) /* action */))
.toEqual({lineWidth: newLineWidth});
expect(lineReducer(2 /* state */, changeLineWidth(-1) /* action */))
.toEqual({lineWidth: 1});
});
test('invalidChangeLineWidth', () => {
const origState = {lineWidth: 2};
expect(lineReducer(origState /* state */, changeLineWidth('invalid argument') /* action */))
.toBe(origState);
expect(lineReducer(origState /* state */, changeLineWidth() /* action */))
.toBe(origState);
});

View file

@ -1,4 +1,3 @@
const defaultsDeep = require('lodash.defaultsdeep'); const defaultsDeep = require('lodash.defaultsdeep');
const path = require('path'); const path = require('path');
const webpack = require('webpack'); const webpack = require('webpack');
@ -96,7 +95,7 @@ module.exports = [
output: { output: {
path: path.resolve(__dirname, 'dist'), path: path.resolve(__dirname, 'dist'),
filename: '[name].js', filename: '[name].js',
libraryTarget: 'commonjs2', libraryTarget: 'commonjs2'
} }
}) })
]; ];