Skip to content

Commit 0ad76f3

Browse files
committed
add pen tool (it just works:tm:)
1 parent 95e9c0e commit 0ad76f3

File tree

9 files changed

+349
-1
lines changed

9 files changed

+349
-1
lines changed

src/components/paint-editor/paint-editor.jsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import Loupe from '../loupe/loupe.jsx';
2727
import FixedToolsContainer from '../../containers/fixed-tools.jsx';
2828
import ModeToolsContainer from '../../containers/mode-tools.jsx';
2929
import OvalMode from '../../containers/oval-mode.jsx';
30+
import PenMode from '../../containers/pen-mode.jsx';
3031
import RectMode from '../../containers/rect-mode.jsx';
3132
import RoundedRectMode from '../../containers/rounded-rect-mode.jsx';
3233
import SussyMode from '../../containers/sussy-mode.jsx';
@@ -158,6 +159,9 @@ const PaintEditorComponent = props => (
158159
<EraserMode
159160
onUpdateImage={props.onUpdateImage}
160161
/>
162+
<PenMode
163+
onUpdateSvg={props.onUpdateImage}
164+
/>
161165
<FillMode
162166
onUpdateImage={props.onUpdateImage}
163167
/>
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import React from 'react';
2+
import PropTypes from 'prop-types';
3+
import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx';
4+
5+
import penIcon from './pen.svg';
6+
7+
const PenModeComponent = props => (
8+
<ToolSelectComponent
9+
imgDescriptor={{
10+
defaultMessage: 'Pen',
11+
description: 'Label for the pen tool, which draws outlines',
12+
id: 'paint.penMode.pen'
13+
}}
14+
imgSrc={penIcon}
15+
isSelected={props.isSelected}
16+
onMouseDown={props.onMouseDown}
17+
/>
18+
);
19+
20+
PenModeComponent.propTypes = {
21+
isSelected: PropTypes.bool.isRequired,
22+
onMouseDown: PropTypes.func.isRequired
23+
};
24+
25+
export default PenModeComponent;

src/components/pen-mode/pen.svg

Lines changed: 12 additions & 0 deletions
Loading

src/containers/fill-color-indicator.jsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const FillColorIndicator = makeColorIndicator(messages.label, false);
2424

2525
const mapStateToProps = state => ({
2626
colorIndex: state.scratchPaint.fillMode.colorIndex,
27-
disabled: state.scratchPaint.mode === Modes.LINE,
27+
disabled: state.scratchPaint.mode === Modes.PEN,
2828
color: state.scratchPaint.color.fillColor.primary,
2929
color2: state.scratchPaint.color.fillColor.secondary,
3030
colorModalVisible: state.scratchPaint.modals.fillColor,

src/containers/pen-mode.jsx

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import PropTypes from 'prop-types';
2+
import React from 'react';
3+
import {connect} from 'react-redux';
4+
import bindAll from 'lodash.bindall';
5+
import Modes from '../lib/modes';
6+
7+
import {changeStrokeColor} from '../reducers/stroke-style';
8+
import {changeStrokeWidth} from '../reducers/stroke-width';
9+
import {changeMode} from '../reducers/modes';
10+
import {clearSelectedItems} from '../reducers/selected-items';
11+
import {MIXED} from '../helper/style-path';
12+
13+
import {clearSelection} from '../helper/selection';
14+
import PenTool from '../helper/tools/pen-tool';
15+
import PenModeComponent from '../components/pen-mode/pen-mode.jsx';
16+
17+
class PenMode extends React.Component {
18+
static get DEFAULT_COLOR () {
19+
return '#000000';
20+
}
21+
constructor (props) {
22+
super(props);
23+
bindAll(this, [
24+
'activateTool',
25+
'deactivateTool'
26+
]);
27+
}
28+
componentDidMount () {
29+
if (this.props.isPenModeActive) {
30+
this.activateTool(this.props);
31+
}
32+
}
33+
componentWillReceiveProps (nextProps) {
34+
if (this.tool &&
35+
(nextProps.colorState.strokeColor !== this.props.colorState.strokeColor ||
36+
nextProps.colorState.strokeWidth !== this.props.colorState.strokeWidth)) {
37+
this.tool.setColorState(nextProps.colorState);
38+
}
39+
40+
if (nextProps.isPenModeActive && !this.props.isPenModeActive) {
41+
this.activateTool();
42+
} else if (!nextProps.isPenModeActive && this.props.isPenModeActive) {
43+
this.deactivateTool();
44+
}
45+
}
46+
shouldComponentUpdate (nextProps) {
47+
return nextProps.isPenModeActive !== this.props.isPenModeActive;
48+
}
49+
activateTool () {
50+
clearSelection(this.props.clearSelectedItems);
51+
// Force the default pen color if stroke is MIXED or transparent
52+
const {strokeColor} = this.props.colorState;
53+
if (strokeColor === MIXED || strokeColor === null) {
54+
this.props.onChangeStrokeColor(PenMode.DEFAULT_COLOR);
55+
}
56+
// Force a minimum stroke width
57+
if (!this.props.colorState.strokeWidth) {
58+
this.props.onChangeStrokeWidth(1);
59+
}
60+
this.tool = new PenTool(
61+
this.props.clearSelectedItems,
62+
this.props.onUpdateSvg
63+
);
64+
this.tool.setColorState(this.props.colorState);
65+
this.tool.activate();
66+
}
67+
deactivateTool () {
68+
this.tool.deactivateTool();
69+
this.tool.remove();
70+
this.tool = null;
71+
}
72+
render () {
73+
return (
74+
<PenModeComponent
75+
isSelected={this.props.isPenModeActive}
76+
onMouseDown={this.props.handleMouseDown}
77+
/>
78+
);
79+
}
80+
}
81+
82+
PenMode.propTypes = {
83+
clearSelectedItems: PropTypes.func.isRequired,
84+
colorState: PropTypes.shape({
85+
fillColor: PropTypes.string,
86+
strokeColor: PropTypes.string,
87+
strokeWidth: PropTypes.number
88+
}).isRequired,
89+
handleMouseDown: PropTypes.func.isRequired,
90+
isPenModeActive: PropTypes.bool.isRequired,
91+
onChangeStrokeColor: PropTypes.func.isRequired,
92+
onChangeStrokeWidth: PropTypes.func.isRequired,
93+
onUpdateSvg: PropTypes.func.isRequired
94+
};
95+
96+
const mapStateToProps = state => ({
97+
colorState: state.scratchPaint.color,
98+
isPenModeActive: state.scratchPaint.mode === Modes.PEN
99+
100+
});
101+
const mapDispatchToProps = dispatch => ({
102+
clearSelectedItems: () => {
103+
dispatch(clearSelectedItems());
104+
},
105+
handleMouseDown: () => {
106+
dispatch(changeMode(Modes.PEN));
107+
},
108+
deactivateTool () {
109+
},
110+
onChangeStrokeColor: strokeColor => {
111+
dispatch(changeStrokeColor(strokeColor));
112+
},
113+
onChangeStrokeWidth: strokeWidth => {
114+
dispatch(changeStrokeWidth(strokeWidth));
115+
}
116+
});
117+
118+
export default connect(
119+
mapStateToProps,
120+
mapDispatchToProps
121+
)(PenMode);

src/helper/style-path.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -602,6 +602,14 @@ const styleBlob = function (path, options) {
602602
}
603603
};
604604

605+
const stylePath = function (path, strokeColor, strokeWidth) {
606+
// Make sure a visible line is drawn
607+
path.setStrokeColor(
608+
(strokeColor === MIXED || strokeColor === null) ? 'black' : strokeColor);
609+
path.setStrokeWidth(
610+
strokeWidth === null || strokeWidth === 0 ? 1 : strokeWidth);
611+
};
612+
605613
const styleCursorPreview = function (path, options) {
606614
if (options.isEraser) {
607615
path.fillColor = 'white';
@@ -647,6 +655,7 @@ export {
647655
MIXED,
648656
styleBlob,
649657
styleShape,
658+
stylePath,
650659
styleCursorPreview,
651660
swapColorsInSelection
652661
};

src/helper/tools/pen-tool.js

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import paper from '@scratch/paper';
2+
import {stylePath} from '../style-path';
3+
import {endPointHit, touching} from '../snapping';
4+
import {drawHitPoint, removeHitPoint} from '../guides';
5+
6+
/**
7+
* Tool to handle freehand drawing of lines.
8+
*/
9+
class PenTool extends paper.Tool {
10+
static get SNAP_TOLERANCE () {
11+
return 5;
12+
}
13+
/** Smaller numbers match the line more closely, larger numbers for smoother curves */
14+
static get SMOOTHING () {
15+
return 2;
16+
}
17+
/**
18+
* @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state
19+
* @param {!function} onUpdateSvg A callback to call when the image visibly changes
20+
*/
21+
constructor (clearSelectedItems, onUpdateSvg) {
22+
super();
23+
this.clearSelectedItems = clearSelectedItems;
24+
this.onUpdateSvg = onUpdateSvg;
25+
26+
this.colorState = null;
27+
this.path = null;
28+
this.hitResult = null;
29+
30+
// Piece of whole path that was added by last stroke. Used to smooth just the added part.
31+
this.subpath = null;
32+
this.subpathIndex = 0;
33+
34+
// We have to set these functions instead of just declaring them because
35+
// paper.js tools hook up the listeners in the setter functions.
36+
this.onMouseDown = this.handleMouseDown;
37+
this.onMouseMove = this.handleMouseMove;
38+
this.onMouseDrag = this.handleMouseDrag;
39+
this.onMouseUp = this.handleMouseUp;
40+
41+
this.fixedDistance = 2;
42+
}
43+
setColorState (colorState) {
44+
this.colorState = colorState;
45+
}
46+
drawHitPoint (hitResult) {
47+
// If near another path's endpoint, draw hit point to indicate that paths would merge
48+
if (hitResult) {
49+
const hitPath = hitResult.path;
50+
if (hitResult.isFirst) {
51+
drawHitPoint(hitPath.firstSegment.point);
52+
} else {
53+
drawHitPoint(hitPath.lastSegment.point);
54+
}
55+
}
56+
}
57+
handleMouseDown (event) {
58+
if (event.event.button > 0) return; // only first mouse button
59+
this.subpath = new paper.Path({insert: false});
60+
61+
// If you click near a point, continue that line instead of making a new line
62+
this.hitResult = endPointHit(event.point, PenTool.SNAP_TOLERANCE);
63+
if (this.hitResult) {
64+
this.path = this.hitResult.path;
65+
stylePath(this.path, this.colorState.strokeColor, this.colorState.strokeWidth);
66+
if (this.hitResult.isFirst) {
67+
this.path.reverse();
68+
}
69+
this.subpathIndex = this.path.segments.length;
70+
this.path.lastSegment.handleOut = null; // Don't interfere with the curvature of the existing path
71+
this.path.lastSegment.handleIn = null;
72+
}
73+
74+
// If not near other path, start a new path
75+
if (!this.path) {
76+
this.path = new paper.Path();
77+
stylePath(this.path, this.colorState.strokeColor, this.colorState.strokeWidth);
78+
this.path.add(event.point);
79+
this.subpath.add(event.point);
80+
paper.view.draw();
81+
}
82+
}
83+
handleMouseMove (event) {
84+
// If near another path's endpoint, or this path's beginpoint, clip to it to suggest
85+
// joining/closing the paths.
86+
if (this.hitResult) {
87+
removeHitPoint();
88+
}
89+
this.hitResult = endPointHit(event.point, PenTool.SNAP_TOLERANCE);
90+
this.drawHitPoint(this.hitResult);
91+
}
92+
handleMouseDrag (event) {
93+
if (event.event.button > 0) return; // only first mouse button
94+
95+
// If near another path's endpoint, or this path's beginpoint, highlight it to suggest
96+
// joining/closing the paths.
97+
if (this.hitResult) {
98+
removeHitPoint();
99+
this.hitResult = null;
100+
}
101+
102+
if (this.path &&
103+
!this.path.closed &&
104+
this.path.segments.length > 3 &&
105+
touching(this.path.firstSegment.point, event.point, PenTool.SNAP_TOLERANCE)) {
106+
this.hitResult = {
107+
path: this.path,
108+
segment: this.path.firstSegment,
109+
isFirst: true
110+
};
111+
} else {
112+
this.hitResult = endPointHit(event.point, PenTool.SNAP_TOLERANCE, this.path);
113+
}
114+
if (this.hitResult) {
115+
this.drawHitPoint(this.hitResult);
116+
}
117+
118+
this.path.add(event.point);
119+
this.subpath.add(event.point);
120+
}
121+
handleMouseUp (event) {
122+
if (event.event.button > 0) return; // only first mouse button
123+
124+
// If I single clicked, don't do anything
125+
if (!this.hitResult && // Might be connecting 2 points that are very close
126+
(this.path.segments.length < 2 ||
127+
(this.path.segments.length === 2 &&
128+
touching(this.path.firstSegment.point, event.point, PenTool.SNAP_TOLERANCE)))) {
129+
this.path.remove();
130+
this.path = null;
131+
return;
132+
}
133+
134+
// Smooth only the added portion
135+
const hasStartConnection = this.subpathIndex > 0;
136+
const hasEndConnection = !!this.hitResult;
137+
this.path.removeSegments(this.subpathIndex);
138+
this.subpath.simplify(this.SMOOTHING);
139+
if (hasStartConnection && this.subpath.length > 0) {
140+
this.subpath.removeSegment(0);
141+
}
142+
if (hasEndConnection && this.subpath.length > 0) {
143+
this.subpath.removeSegment(this.subpath.length - 1);
144+
}
145+
this.path.insertSegments(this.subpathIndex, this.subpath.segments);
146+
this.subpath = null;
147+
this.subpathIndex = 0;
148+
149+
// If I intersect other line end points, join or close
150+
if (this.hitResult) {
151+
if (touching(this.path.firstSegment.point, this.hitResult.segment.point, PenTool.SNAP_TOLERANCE)) {
152+
// close path
153+
this.path.closed = true;
154+
} else {
155+
// joining two paths
156+
if (!this.hitResult.isFirst) {
157+
this.hitResult.path.reverse();
158+
}
159+
this.path.join(this.hitResult.path);
160+
}
161+
removeHitPoint();
162+
this.hitResult = null;
163+
}
164+
165+
if (this.path) {
166+
this.onUpdateSvg();
167+
this.path = null;
168+
}
169+
}
170+
deactivateTool () {
171+
this.fixedDistance = 1;
172+
}
173+
}
174+
175+
export default PenTool;

src/lib/modes.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const vectorModesObj = {
77
FILL: null,
88
SELECT: null,
99
RESHAPE: null,
10+
PEN: null,
1011
OVAL: null,
1112
RECT: null,
1213
ROUNDED_RECT: null,

0 commit comments

Comments
 (0)