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 @@ + + + + brush + Created with Sketch. + + + + + \ 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,