diff --git a/src/components/bit-brush-mode/bit-brush-mode.jsx b/src/components/bit-brush-mode/bit-brush-mode.jsx
new file mode 100644
index 00000000..d039e30d
--- /dev/null
+++ b/src/components/bit-brush-mode/bit-brush-mode.jsx
@@ -0,0 +1,25 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx';
+
+import brushIcon from './brush.svg';
+
+const BitBrushModeComponent = props => (
+
+);
+
+BitBrushModeComponent.propTypes = {
+ isSelected: PropTypes.bool.isRequired,
+ onMouseDown: PropTypes.func.isRequired
+};
+
+export default BitBrushModeComponent;
diff --git a/src/components/bit-brush-mode/brush.svg b/src/components/bit-brush-mode/brush.svg
new file mode 100644
index 00000000..18149bcf
--- /dev/null
+++ b/src/components/bit-brush-mode/brush.svg
@@ -0,0 +1,10 @@
+
+
\ No newline at end of file
diff --git a/src/components/mode-tools/mode-tools.jsx b/src/components/mode-tools/mode-tools.jsx
index fd788ba3..6356f9de 100644
--- a/src/components/mode-tools/mode-tools.jsx
+++ b/src/components/mode-tools/mode-tools.jsx
@@ -6,6 +6,7 @@ import React from 'react';
import {changeBrushSize} from '../../reducers/brush-mode';
import {changeBrushSize as changeEraserSize} from '../../reducers/eraser-mode';
+import {changeBitBrushSize} from '../../reducers/bit-brush-size';
import LiveInputHOC from '../forms/live-input-hoc.jsx';
import {defineMessages, injectIntl, intlShape} from 'react-intl';
@@ -13,12 +14,15 @@ import Input from '../forms/input.jsx';
import InputGroup from '../input-group/input-group.jsx';
import LabeledIconButton from '../labeled-icon-button/labeled-icon-button.jsx';
import Modes from '../../lib/modes';
+import Formats from '../../lib/format';
+import {isBitmap} from '../../lib/format';
import styles from './mode-tools.css';
import copyIcon from './icons/copy.svg';
import pasteIcon from './icons/paste.svg';
import brushIcon from '../brush-mode/brush.svg';
+import bitBrushIcon from '../bit-brush-mode/brush.svg';
import curvedPointIcon from './icons/curved-point.svg';
import eraserIcon from '../eraser-mode/eraser.svg';
import flipHorizontalIcon from './icons/flip-horizontal.svg';
@@ -74,6 +78,12 @@ const ModeToolsComponent = props => {
switch (props.mode) {
case Modes.BRUSH:
+ /* falls through */
+ case Modes.BIT_BRUSH:
+ {
+ const currentBrushIcon = isBitmap(props.format) ? bitBrushIcon : brushIcon;
+ const currentBrushValue = isBitmap(props.format) ? props.bitBrushSize : props.brushValue;
+ const changeFunction = isBitmap(props.format) ? props.onBitBrushSliderChange : props.onBrushSliderChange;
return (
@@ -81,7 +91,7 @@ const ModeToolsComponent = props => {
alt={props.intl.formatMessage(messages.brushSize)}
className={styles.modeToolsIcon}
draggable={false}
- src={brushIcon}
+ src={currentBrushIcon}
/>
{
max={MAX_STROKE_WIDTH}
min="1"
type="number"
- value={props.brushValue}
- onSubmit={props.onBrushSliderChange}
+ value={currentBrushValue}
+ onSubmit={changeFunction}
/>
);
+ }
case Modes.ERASER:
return (
@@ -174,15 +185,18 @@ const ModeToolsComponent = props => {
};
ModeToolsComponent.propTypes = {
+ bitBrushSize: PropTypes.number,
brushValue: PropTypes.number,
className: PropTypes.string,
clipboardItems: PropTypes.arrayOf(PropTypes.array),
eraserValue: PropTypes.number,
+ format: PropTypes.oneOf(Object.keys(Formats)).isRequired,
hasSelectedUncurvedPoints: PropTypes.bool,
hasSelectedUnpointedPoints: PropTypes.bool,
intl: intlShape.isRequired,
mode: PropTypes.string.isRequired,
- onBrushSliderChange: PropTypes.func,
+ onBitBrushSliderChange: PropTypes.func.isRequired,
+ onBrushSliderChange: PropTypes.func.isRequired,
onCopyToClipboard: PropTypes.func.isRequired,
onCurvePoints: PropTypes.func.isRequired,
onEraserSliderChange: PropTypes.func,
@@ -195,6 +209,8 @@ ModeToolsComponent.propTypes = {
const mapStateToProps = state => ({
mode: state.scratchPaint.mode,
+ format: state.scratchPaint.format,
+ bitBrushSize: state.scratchPaint.bitBrushSize,
brushValue: state.scratchPaint.brushMode.brushSize,
clipboardItems: state.scratchPaint.clipboard.items,
eraserValue: state.scratchPaint.eraserMode.brushSize,
@@ -204,6 +220,9 @@ const mapDispatchToProps = dispatch => ({
onBrushSliderChange: brushSize => {
dispatch(changeBrushSize(brushSize));
},
+ onBitBrushSliderChange: bitBrushSize => {
+ dispatch(changeBitBrushSize(bitBrushSize));
+ },
onEraserSliderChange: eraserSize => {
dispatch(changeEraserSize(eraserSize));
}
diff --git a/src/components/paint-editor/paint-editor.jsx b/src/components/paint-editor/paint-editor.jsx
index 771bf79e..5504d6aa 100644
--- a/src/components/paint-editor/paint-editor.jsx
+++ b/src/components/paint-editor/paint-editor.jsx
@@ -10,6 +10,7 @@ import PaperCanvas from '../../containers/paper-canvas.jsx';
import {shouldShowGroup, shouldShowUngroup} from '../../helper/group';
import {shouldShowBringForward, shouldShowSendBackward} from '../../helper/order';
+import BitBrushMode from '../../containers/bit-brush-mode.jsx';
import Box from '../box/box.jsx';
import Button from '../button/button.jsx';
import ButtonGroup from '../button-group/button-group.jsx';
@@ -35,7 +36,7 @@ import StrokeWidthIndicatorComponent from '../../containers/stroke-width-indicat
import TextMode from '../../containers/text-mode.jsx';
import Formats from '../../lib/format';
-import {isVector} from '../../lib/format';
+import {isBitmap, isVector} from '../../lib/format';
import layout from '../../lib/layout-constants';
import styles from './paint-editor.css';
@@ -309,34 +310,56 @@ const PaintEditorComponent = props => {
{/* Second Row */}
-
-
- {/* fill */}
-
- {/* stroke */}
-
- {/* stroke width */}
-
-
-
-
-
-
+ {isVector(props.format) ?
+
+
+ {/* fill */}
+
+ {/* stroke */}
+
+ {/* stroke width */}
+
+
+
+
+
+
:
+
+
+ {/* fill */}
+
+
+
+
+
+
+ }
) : null}
@@ -375,6 +398,14 @@ const PaintEditorComponent = props => {
) : null}
+ {props.canvas !== null ? ( // eslint-disable-line no-negated-condition
+
+
+
+ ) : null}
+
{/* Canvas */}
+ );
+ }
+}
+
+BitBrushMode.propTypes = {
+ bitBrushSize: PropTypes.number.isRequired,
+ clearSelectedItems: PropTypes.func.isRequired,
+ color: PropTypes.string,
+ handleMouseDown: PropTypes.func.isRequired,
+ isBitBrushModeActive: PropTypes.bool.isRequired,
+ onChangeFillColor: PropTypes.func.isRequired,
+ onUpdateSvg: PropTypes.func.isRequired
+};
+
+const mapStateToProps = state => ({
+ bitBrushSize: state.scratchPaint.bitBrushSize,
+ color: state.scratchPaint.color.fillColor,
+ isBitBrushModeActive: state.scratchPaint.mode === Modes.BIT_BRUSH
+});
+const mapDispatchToProps = dispatch => ({
+ clearSelectedItems: () => {
+ dispatch(clearSelectedItems());
+ },
+ handleMouseDown: () => {
+ dispatch(changeMode(Modes.BIT_BRUSH));
+ },
+ onChangeFillColor: fillColor => {
+ dispatch(changeFillColor(fillColor));
+ }
+});
+
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(BitBrushMode);
diff --git a/src/containers/brush-mode.jsx b/src/containers/brush-mode.jsx
index 6c089a8d..7584904a 100644
--- a/src/containers/brush-mode.jsx
+++ b/src/containers/brush-mode.jsx
@@ -7,7 +7,6 @@ import Blobbiness from '../helper/blob-tools/blob';
import {MIXED} from '../helper/style-path';
import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-color';
-import {changeBrushSize} from '../reducers/brush-mode';
import {changeMode} from '../reducers/modes';
import {clearSelectedItems} from '../reducers/selected-items';
import {clearSelection} from '../helper/selection';
@@ -98,9 +97,6 @@ const mapDispatchToProps = dispatch => ({
clearSelectedItems: () => {
dispatch(clearSelectedItems());
},
- changeBrushSize: brushSize => {
- dispatch(changeBrushSize(brushSize));
- },
handleMouseDown: () => {
dispatch(changeMode(Modes.BRUSH));
},
diff --git a/src/containers/paint-editor.jsx b/src/containers/paint-editor.jsx
index 61feaf99..93a24104 100644
--- a/src/containers/paint-editor.jsx
+++ b/src/containers/paint-editor.jsx
@@ -23,7 +23,7 @@ import EyeDropperTool from '../helper/tools/eye-dropper';
import Modes from '../lib/modes';
import Formats from '../lib/format';
-import {isBitmap} from '../lib/format';
+import {isBitmap, isVector} from '../lib/format';
import {connect} from 'react-redux';
import bindAll from 'lodash.bindall';
@@ -79,6 +79,13 @@ class PaintEditor extends React.Component {
} else if (!this.props.isEyeDropping && prevProps.isEyeDropping) {
this.stopEyeDroppingLoop();
}
+
+ // @todo move to correct corresponding tool
+ if (isVector(this.props.format) && isBitmap(prevProps.format)) {
+ this.props.changeMode(Modes.BRUSH);
+ } else if (isVector(prevProps.format) && isBitmap(this.props.format)) {
+ this.props.changeMode(Modes.BIT_BRUSH);
+ }
}
componentWillUnmount () {
document.removeEventListener('keydown', this.props.onKeyPress);
@@ -280,6 +287,7 @@ class PaintEditor extends React.Component {
PaintEditor.propTypes = {
changeColorToEyeDropper: PropTypes.func,
+ changeMode: PropTypes.func.isRequired,
clearSelectedItems: PropTypes.func.isRequired,
format: PropTypes.oneOf(Object.keys(Formats)).isRequired,
handleSwitchToBitmap: PropTypes.func.isRequired,
@@ -344,6 +352,9 @@ const mapDispatchToProps = dispatch => ({
dispatch(changeMode(Modes.RECT));
}
},
+ changeMode: mode => {
+ dispatch(changeMode(mode));
+ },
clearSelectedItems: () => {
dispatch(clearSelectedItems());
},
diff --git a/src/helper/bit-tools/brush-tool.js b/src/helper/bit-tools/brush-tool.js
new file mode 100644
index 00000000..da173899
--- /dev/null
+++ b/src/helper/bit-tools/brush-tool.js
@@ -0,0 +1,128 @@
+import paper from '@scratch/paper';
+import {getRaster} from '../layer';
+import {forEachLinePoint, fillEllipse} from '../bitmap';
+import {getGuideLayer} from '../layer';
+
+/**
+ * Tool for drawing with the bitmap brush.
+ */
+class BrushTool extends paper.Tool {
+ /**
+ * @param {!function} onUpdateSvg A callback to call when the image visibly changes
+ */
+ constructor (onUpdateSvg) {
+ super();
+ this.onUpdateSvg = onUpdateSvg;
+
+ // We have to set these functions instead of just declaring them because
+ // paper.js tools hook up the listeners in the setter functions.
+ this.onMouseMove = this.handleMouseMove;
+ this.onMouseDown = this.handleMouseDown;
+ this.onMouseDrag = this.handleMouseDrag;
+ this.onMouseUp = this.handleMouseUp;
+
+ this.colorState = null;
+ this.active = false;
+ this.lastPoint = null;
+ this.cursorPreview = null;
+ }
+ setColor (color) {
+ this.color = color;
+ }
+ setBrushSize (size) {
+ // For performance, make sure this is an integer
+ this.size = Math.max(1, ~~size);
+ }
+ // Draw a brush mark at the given point
+ draw (x, y) {
+ const roundedUpRadius = Math.ceil(this.size / 2);
+ getRaster().drawImage(this.tmpCanvas, new paper.Point(~~x - roundedUpRadius, ~~y - roundedUpRadius));
+ }
+ updateCursorIfNeeded () {
+ if (!this.size) {
+ return;
+ }
+ // The cursor preview was unattached from the view by an outside process,
+ // such as changing costumes or undo.
+ if (this.cursorPreview && !this.cursorPreview.parent) {
+ this.cursorPreview = null;
+ }
+
+ if (!this.cursorPreview || !(this.lastSize === this.size && this.lastColor === this.color)) {
+ if (this.cursorPreview) {
+ this.cursorPreview.remove();
+ }
+
+ this.tmpCanvas = document.createElement('canvas');
+ const roundedUpRadius = Math.ceil(this.size / 2);
+ this.tmpCanvas.width = roundedUpRadius * 2;
+ this.tmpCanvas.height = roundedUpRadius * 2;
+ const context = this.tmpCanvas.getContext('2d');
+ context.imageSmoothingEnabled = false;
+ context.fillStyle = this.color;
+ // Small squares for pixel artists
+ if (this.size <= 5) {
+ if (this.size % 2) {
+ context.fillRect(1, 1, this.size, this.size);
+ } else {
+ context.fillRect(0, 0, this.size, this.size);
+ }
+ } else {
+ const roundedDownRadius = ~~(this.size / 2);
+ fillEllipse(roundedDownRadius, roundedDownRadius, roundedDownRadius, roundedDownRadius, context);
+ }
+
+ this.cursorPreview = new paper.Raster(this.tmpCanvas);
+ this.cursorPreview.guide = true;
+ this.cursorPreview.parent = getGuideLayer();
+ this.cursorPreview.data.isHelperItem = true;
+ }
+ this.lastSize = this.size;
+ this.lastColor = this.color;
+ }
+ handleMouseMove (event) {
+ this.updateCursorIfNeeded();
+ this.cursorPreview.position = new paper.Point(~~event.point.x, ~~event.point.y);
+ }
+ handleMouseDown (event) {
+ if (event.event.button > 0) return; // only first mouse button
+ this.active = true;
+
+ this.cursorPreview.remove();
+
+ this.draw(event.point.x, event.point.y);
+ this.lastPoint = event.point;
+ }
+ handleMouseDrag (event) {
+ if (event.event.button > 0 || !this.active) return; // only first mouse button
+
+ if (this.isBoundingBoxMode) {
+ this.boundingBoxTool.onMouseDrag(event);
+ return;
+ }
+ forEachLinePoint(this.lastPoint, event.point, this.draw.bind(this));
+ this.lastPoint = event.point;
+ }
+ handleMouseUp (event) {
+ if (event.event.button > 0 || !this.active) return; // only first mouse button
+
+ forEachLinePoint(this.lastPoint, event.point, this.draw.bind(this));
+ this.onUpdateSvg();
+
+ this.lastPoint = null;
+ this.active = false;
+
+ this.updateCursorIfNeeded();
+ this.cursorPreview.position = new paper.Point(~~event.point.x, ~~event.point.y);
+ }
+ deactivateTool () {
+ this.active = false;
+ this.tmpCanvas = null;
+ if (this.cursorPreview) {
+ this.cursorPreview.remove();
+ this.cursorPreview = null;
+ }
+ }
+}
+
+export default BrushTool;
diff --git a/src/helper/bitmap.js b/src/helper/bitmap.js
index 5c1db739..f4ab30a3 100644
--- a/src/helper/bitmap.js
+++ b/src/helper/bitmap.js
@@ -1,5 +1,86 @@
import paper from '@scratch/paper';
+const forEachLinePoint = function (point1, point2, callback) {
+ // Bresenham line algorithm
+ let x1 = ~~point1.x;
+ const x2 = ~~point2.x;
+ let y1 = ~~point1.y;
+ const y2 = ~~point2.y;
+
+ const dx = Math.abs(x2 - x1);
+ const dy = Math.abs(y2 - y1);
+ const sx = (x1 < x2) ? 1 : -1;
+ const sy = (y1 < y2) ? 1 : -1;
+ let err = dx - dy;
+
+ callback(x1, y1);
+ while (x1 !== x2 || y1 !== y2) {
+ const e2 = err * 2;
+ if (e2 > -dy) {
+ err -= dy;
+ x1 += sx;
+ }
+ if (e2 < dx) {
+ err += dx;
+ y1 += sy;
+ }
+ callback(x1, y1);
+ }
+};
+
+const fillEllipse = function (centerX, centerY, radiusX, radiusY, context) {
+ // Bresenham ellipse algorithm
+ centerX = ~~centerX;
+ centerY = ~~centerY;
+ radiusX = ~~radiusX;
+ radiusY = ~~radiusY;
+ const twoRadXSquared = 2 * radiusX * radiusX;
+ const twoRadYSquared = 2 * radiusY * radiusY;
+ let x = radiusX;
+ let y = 0;
+ let dx = radiusY * radiusY * (1 - (radiusX << 1));
+ let dy = radiusX * radiusX;
+ let error = 0;
+ let stoppingX = twoRadYSquared * radiusX;
+ let stoppingY = 0;
+
+ while (stoppingX >= stoppingY) {
+ context.fillRect(centerX - x, centerY - y, x << 1, y << 1);
+ y++;
+ stoppingY += twoRadXSquared;
+ error += dy;
+ dy += twoRadXSquared;
+ if ((error << 1) + dx > 0) {
+ x--;
+ stoppingX -= twoRadYSquared;
+ error += dx;
+ dx += twoRadYSquared;
+ }
+ }
+
+ x = 0;
+ y = radiusY;
+ dx = radiusY * radiusY;
+ dy = radiusX * radiusX * (1 - (radiusY << 1));
+ error = 0;
+ stoppingX = 0;
+ stoppingY = twoRadXSquared * radiusY;
+ while (stoppingX <= stoppingY) {
+ context.fillRect(centerX - x, centerY - y, x * 2, y * 2);
+ x++;
+ stoppingX += twoRadYSquared;
+ error += dx;
+ dx += twoRadYSquared;
+ if ((error << 1) + dy > 0) {
+ y--;
+ stoppingY -= twoRadXSquared;
+ error += dy;
+ dy += twoRadXSquared;
+ }
+
+ }
+};
+
const rowBlank_ = function (imageData, width, y) {
for (let x = 0; x < width; ++x) {
if (imageData.data[(y * width << 2) + (x << 2) + 3] !== 0) return false;
@@ -33,5 +114,7 @@ const trim = function (raster) {
};
export {
+ fillEllipse,
+ forEachLinePoint,
trim
};
diff --git a/src/helper/layer.js b/src/helper/layer.js
index 837d3747..d6d9b7d6 100644
--- a/src/helper/layer.js
+++ b/src/helper/layer.js
@@ -27,6 +27,15 @@ const clearRaster = function () {
};
const getRaster = function () {
+ const layer = _getLayer('isRasterLayer');
+ // Generate blank raster
+ if (layer.children.length === 0) {
+ const raster = new paper.Raster(rasterSrc);
+ raster.parent = layer;
+ raster.guide = true;
+ raster.locked = true;
+ raster.position = paper.view.center;
+ }
return _getLayer('isRasterLayer').children[0];
};
diff --git a/src/lib/modes.js b/src/lib/modes.js
index 7fcbbe57..4eafc6b5 100644
--- a/src/lib/modes.js
+++ b/src/lib/modes.js
@@ -1,6 +1,7 @@
import keyMirror from 'keymirror';
const Modes = keyMirror({
+ BIT_BRUSH: null,
BRUSH: null,
ERASER: null,
LINE: null,
diff --git a/src/reducers/bit-brush-size.js b/src/reducers/bit-brush-size.js
new file mode 100644
index 00000000..d4754fd7
--- /dev/null
+++ b/src/reducers/bit-brush-size.js
@@ -0,0 +1,33 @@
+import log from '../log/log';
+
+// Bit brush size affects bit brush width, circle/rectangle outline drawing width, and line width
+// in the bitmap paint editor.
+const CHANGE_BIT_BRUSH_SIZE = 'scratch-paint/brush-mode/CHANGE_BIT_BRUSH_SIZE';
+const initialState = 10;
+
+const reducer = function (state, action) {
+ if (typeof state === 'undefined') state = initialState;
+ switch (action.type) {
+ case CHANGE_BIT_BRUSH_SIZE:
+ if (isNaN(action.brushSize)) {
+ log.warn(`Invalid brush size: ${action.brushSize}`);
+ return state;
+ }
+ return Math.max(1, action.brushSize);
+ default:
+ return state;
+ }
+};
+
+// Action creators ==================================
+const changeBitBrushSize = function (brushSize) {
+ return {
+ type: CHANGE_BIT_BRUSH_SIZE,
+ brushSize: brushSize
+ };
+};
+
+export {
+ reducer as default,
+ changeBitBrushSize
+};
diff --git a/src/reducers/scratch-paint-reducer.js b/src/reducers/scratch-paint-reducer.js
index 224dcad7..3527051d 100644
--- a/src/reducers/scratch-paint-reducer.js
+++ b/src/reducers/scratch-paint-reducer.js
@@ -1,5 +1,6 @@
import {combineReducers} from 'redux';
import modeReducer from './modes';
+import bitBrushSizeReducer from './bit-brush-size';
import brushModeReducer from './brush-mode';
import eraserModeReducer from './eraser-mode';
import colorReducer from './color';
@@ -14,6 +15,7 @@ import undoReducer from './undo';
export default combineReducers({
mode: modeReducer,
+ bitBrushSize: bitBrushSizeReducer,
brushMode: brushModeReducer,
color: colorReducer,
clipboard: clipboardReducer,