Bitmap eraser tool (#507)

This commit is contained in:
DD Liu 2018-06-14 10:35:02 -04:00 committed by GitHub
parent 689d4fb0a7
commit 4cadcb3da3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 186 additions and 29 deletions

View file

@ -1,27 +1,26 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types';
import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx';
import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx'; import ToolSelectComponent from '../tool-select-base/tool-select-base.jsx';
import eraserIcon from './eraser.svg'; import eraserIcon from './eraser.svg';
const BitEraserComponent = () => ( const BitEraserComponent = props => (
<ComingSoonTooltip <ToolSelectComponent
place="right" imgDescriptor={{
tooltipId="bit-eraser-mode" defaultMessage: 'Eraser',
> description: 'Label for the eraser tool',
<ToolSelectComponent id: 'paint.eraserMode.eraser'
disabled }}
imgDescriptor={{ imgSrc={eraserIcon}
defaultMessage: 'Eraser', isSelected={props.isSelected}
description: 'Label for the eraser tool', onMouseDown={props.onMouseDown}
id: 'paint.eraserMode.eraser' />
}}
imgSrc={eraserIcon}
isSelected={false}
onMouseDown={function () {}} // eslint-disable-line react/jsx-no-bind
/>
</ComingSoonTooltip>
); );
BitEraserComponent.propTypes = {
isSelected: PropTypes.bool.isRequired,
onMouseDown: PropTypes.func.isRequired
};
export default BitEraserComponent; export default BitEraserComponent;

View file

@ -7,6 +7,7 @@ import React from 'react';
import {changeBrushSize} from '../../reducers/brush-mode'; import {changeBrushSize} from '../../reducers/brush-mode';
import {changeBrushSize as changeEraserSize} from '../../reducers/eraser-mode'; import {changeBrushSize as changeEraserSize} from '../../reducers/eraser-mode';
import {changeBitBrushSize} from '../../reducers/bit-brush-size'; import {changeBitBrushSize} from '../../reducers/bit-brush-size';
import {changeBitEraserSize} from '../../reducers/bit-eraser-size';
import FontDropdown from '../../containers/font-dropdown.jsx'; import FontDropdown from '../../containers/font-dropdown.jsx';
import LiveInputHOC from '../forms/live-input-hoc.jsx'; import LiveInputHOC from '../forms/live-input-hoc.jsx';
@ -23,6 +24,7 @@ import copyIcon from './icons/copy.svg';
import pasteIcon from './icons/paste.svg'; import pasteIcon from './icons/paste.svg';
import bitBrushIcon from '../bit-brush-mode/brush.svg'; import bitBrushIcon from '../bit-brush-mode/brush.svg';
import bitEraserIcon from '../bit-eraser-mode/eraser.svg';
import bitLineIcon from '../bit-line-mode/line.svg'; import bitLineIcon from '../bit-line-mode/line.svg';
import brushIcon from '../brush-mode/brush.svg'; import brushIcon from '../brush-mode/brush.svg';
import curvedPointIcon from './icons/curved-point.svg'; import curvedPointIcon from './icons/curved-point.svg';
@ -117,7 +119,13 @@ const ModeToolsComponent = props => {
</div> </div>
); );
} }
case Modes.BIT_ERASER:
/* falls through */
case Modes.ERASER: case Modes.ERASER:
{
const currentIcon = isVector(props.format) ? eraserIcon : bitEraserIcon;
const currentEraserValue = isBitmap(props.format) ? props.bitEraserSize : props.eraserValue;
const changeFunction = isBitmap(props.format) ? props.onBitEraserSliderChange : props.onEraserSliderChange;
return ( return (
<div className={classNames(props.className, styles.modeTools)}> <div className={classNames(props.className, styles.modeTools)}>
<div> <div>
@ -125,7 +133,7 @@ const ModeToolsComponent = props => {
alt={props.intl.formatMessage(messages.eraserSize)} alt={props.intl.formatMessage(messages.eraserSize)}
className={styles.modeToolsIcon} className={styles.modeToolsIcon}
draggable={false} draggable={false}
src={eraserIcon} src={currentIcon}
/> />
</div> </div>
<LiveInput <LiveInput
@ -134,11 +142,12 @@ const ModeToolsComponent = props => {
max={MAX_STROKE_WIDTH} max={MAX_STROKE_WIDTH}
min="1" min="1"
type="number" type="number"
value={props.eraserValue} value={currentEraserValue}
onSubmit={props.onEraserSliderChange} onSubmit={changeFunction}
/> />
</div> </div>
); );
}
case Modes.RESHAPE: case Modes.RESHAPE:
return ( return (
<div className={classNames(props.className, styles.modeTools)}> <div className={classNames(props.className, styles.modeTools)}>
@ -207,6 +216,7 @@ const ModeToolsComponent = props => {
ModeToolsComponent.propTypes = { ModeToolsComponent.propTypes = {
bitBrushSize: PropTypes.number, bitBrushSize: PropTypes.number,
bitEraserSize: PropTypes.number,
brushValue: PropTypes.number, brushValue: PropTypes.number,
className: PropTypes.string, className: PropTypes.string,
clipboardItems: PropTypes.arrayOf(PropTypes.array), clipboardItems: PropTypes.arrayOf(PropTypes.array),
@ -233,6 +243,7 @@ const mapStateToProps = state => ({
mode: state.scratchPaint.mode, mode: state.scratchPaint.mode,
format: state.scratchPaint.format, format: state.scratchPaint.format,
bitBrushSize: state.scratchPaint.bitBrushSize, bitBrushSize: state.scratchPaint.bitBrushSize,
bitEraserSize: state.scratchPaint.bitEraserSize,
brushValue: state.scratchPaint.brushMode.brushSize, brushValue: state.scratchPaint.brushMode.brushSize,
clipboardItems: state.scratchPaint.clipboard.items, clipboardItems: state.scratchPaint.clipboard.items,
eraserValue: state.scratchPaint.eraserMode.brushSize, eraserValue: state.scratchPaint.eraserMode.brushSize,
@ -245,6 +256,9 @@ const mapDispatchToProps = dispatch => ({
onBitBrushSliderChange: bitBrushSize => { onBitBrushSliderChange: bitBrushSize => {
dispatch(changeBitBrushSize(bitBrushSize)); dispatch(changeBitBrushSize(bitBrushSize));
}, },
onBitEraserSliderChange: eraserSize => {
dispatch(changeBitEraserSize(eraserSize));
},
onEraserSliderChange: eraserSize => { onEraserSliderChange: eraserSize => {
dispatch(changeEraserSize(eraserSize)); dispatch(changeEraserSize(eraserSize));
} }

View file

@ -12,7 +12,7 @@ import BitOvalMode from '../../components/bit-oval-mode/bit-oval-mode.jsx';
import BitRectMode from '../../containers/bit-rect-mode.jsx'; import BitRectMode from '../../containers/bit-rect-mode.jsx';
import BitTextMode from '../../components/bit-text-mode/bit-text-mode.jsx'; import BitTextMode from '../../components/bit-text-mode/bit-text-mode.jsx';
import BitFillMode from '../../components/bit-fill-mode/bit-fill-mode.jsx'; import BitFillMode from '../../components/bit-fill-mode/bit-fill-mode.jsx';
import BitEraserMode from '../../components/bit-eraser-mode/bit-eraser-mode.jsx'; import BitEraserMode from '../../containers/bit-eraser-mode.jsx';
import BitSelectMode from '../../components/bit-select-mode/bit-select-mode.jsx'; import BitSelectMode from '../../components/bit-select-mode/bit-select-mode.jsx';
import Box from '../box/box.jsx'; import Box from '../box/box.jsx';
import Button from '../button/button.jsx'; import Button from '../button/button.jsx';
@ -182,7 +182,9 @@ const PaintEditorComponent = props => (
/> />
<BitTextMode /> <BitTextMode />
<BitFillMode /> <BitFillMode />
<BitEraserMode /> <BitEraserMode
onUpdateImage={props.onUpdateImage}
/>
<BitSelectMode /> <BitSelectMode />
</div> </div>
) : null} ) : null}

View file

@ -0,0 +1,90 @@
import PropTypes from 'prop-types';
import React from 'react';
import {connect} from 'react-redux';
import bindAll from 'lodash.bindall';
import Modes from '../lib/modes';
import {changeMode} from '../reducers/modes';
import {clearSelectedItems} from '../reducers/selected-items';
import {clearSelection} from '../helper/selection';
import BitEraserModeComponent from '../components/bit-eraser-mode/bit-eraser-mode.jsx';
import BitBrushTool from '../helper/bit-tools/brush-tool';
class BitEraserMode extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'activateTool',
'deactivateTool'
]);
}
componentDidMount () {
if (this.props.isBitEraserModeActive) {
this.activateTool(this.props);
}
}
componentWillReceiveProps (nextProps) {
if (this.tool && nextProps.bitEraserSize !== this.props.bitEraserSize) {
this.tool.setBrushSize(nextProps.bitEraserSize);
}
if (nextProps.isBitEraserModeActive && !this.props.isBitEraserModeActive) {
this.activateTool();
} else if (!nextProps.isBitEraserModeActive && this.props.isBitEraserModeActive) {
this.deactivateTool();
}
}
shouldComponentUpdate (nextProps) {
return nextProps.isBitEraserModeActive !== this.props.isBitEraserModeActive;
}
activateTool () {
clearSelection(this.props.clearSelectedItems);
this.tool = new BitBrushTool(
this.props.onUpdateImage,
true /* isEraser */
);
this.tool.setBrushSize(this.props.bitEraserSize);
this.tool.activate();
}
deactivateTool () {
this.tool.deactivateTool();
this.tool.remove();
this.tool = null;
}
render () {
return (
<BitEraserModeComponent
isSelected={this.props.isBitEraserModeActive}
onMouseDown={this.props.handleMouseDown}
/>
);
}
}
BitEraserMode.propTypes = {
bitEraserSize: PropTypes.number.isRequired,
clearSelectedItems: PropTypes.func.isRequired,
handleMouseDown: PropTypes.func.isRequired,
isBitEraserModeActive: PropTypes.bool.isRequired,
onUpdateImage: PropTypes.func.isRequired
};
const mapStateToProps = state => ({
bitEraserSize: state.scratchPaint.bitEraserSize,
isBitEraserModeActive: state.scratchPaint.mode === Modes.BIT_ERASER
});
const mapDispatchToProps = dispatch => ({
clearSelectedItems: () => {
dispatch(clearSelectedItems());
},
handleMouseDown: () => {
dispatch(changeMode(Modes.BIT_ERASER));
}
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(BitEraserMode);

View file

@ -124,6 +124,9 @@ class PaintEditor extends React.Component {
case Modes.BIT_RECT: case Modes.BIT_RECT:
this.props.changeMode(Modes.RECT); this.props.changeMode(Modes.RECT);
break; break;
case Modes.BIT_ERASER:
this.props.changeMode(Modes.ERASER);
break;
default: default:
this.props.changeMode(Modes.BRUSH); this.props.changeMode(Modes.BRUSH);
} }
@ -138,6 +141,9 @@ class PaintEditor extends React.Component {
case Modes.RECT: case Modes.RECT:
this.props.changeMode(Modes.BIT_RECT); this.props.changeMode(Modes.BIT_RECT);
break; break;
case Modes.ERASER:
this.props.changeMode(Modes.BIT_ERASER);
break;
default: default:
this.props.changeMode(Modes.BIT_BRUSH); this.props.changeMode(Modes.BIT_BRUSH);
} }

View file

@ -4,15 +4,17 @@ import {forEachLinePoint, getBrushMark} from '../bitmap';
import {getGuideLayer} from '../layer'; import {getGuideLayer} from '../layer';
/** /**
* Tool for drawing with the bitmap brush. * Tool for drawing with the bitmap brush and eraser
*/ */
class BrushTool extends paper.Tool { class BrushTool extends paper.Tool {
/** /**
* @param {!function} onUpdateImage A callback to call when the image visibly changes * @param {!function} onUpdateImage A callback to call when the image visibly changes
* @param {boolean} isEraser True if brush should erase
*/ */
constructor (onUpdateImage) { constructor (onUpdateImage, isEraser) {
super(); super();
this.onUpdateImage = onUpdateImage; this.onUpdateImage = onUpdateImage;
this.isEraser = isEraser;
// We have to set these functions instead of just declaring them because // We have to set these functions instead of just declaring them because
// paper.js tools hook up the listeners in the setter functions. // paper.js tools hook up the listeners in the setter functions.
@ -39,7 +41,14 @@ class BrushTool extends paper.Tool {
this.tmpCanvas = getBrushMark(this.size, this.color); this.tmpCanvas = getBrushMark(this.size, this.color);
} }
const roundedUpRadius = Math.ceil(this.size / 2); const roundedUpRadius = Math.ceil(this.size / 2);
const context = getRaster().getContext('2d');
if (this.isEraser) {
context.globalCompositeOperation = 'destination-out';
}
getRaster().drawImage(this.tmpCanvas, new paper.Point(~~x - roundedUpRadius, ~~y - roundedUpRadius)); getRaster().drawImage(this.tmpCanvas, new paper.Point(~~x - roundedUpRadius, ~~y - roundedUpRadius));
if (this.isEraser) {
context.globalCompositeOperation = 'source-over';
}
} }
updateCursorIfNeeded () { updateCursorIfNeeded () {
if (!this.size) { if (!this.size) {
@ -57,7 +66,7 @@ class BrushTool extends paper.Tool {
this.cursorPreview.remove(); this.cursorPreview.remove();
} }
this.tmpCanvas = getBrushMark(this.size, this.color); this.tmpCanvas = getBrushMark(this.size, this.color, this.isEraser);
this.cursorPreview = new paper.Raster(this.tmpCanvas); this.cursorPreview = new paper.Raster(this.tmpCanvas);
this.cursorPreview.guide = true; this.cursorPreview.guide = true;
this.cursorPreview.parent = getGuideLayer(); this.cursorPreview.parent = getGuideLayer();

View file

@ -86,9 +86,10 @@ const fillEllipse = function (centerX, centerY, radiusX, radiusY, context) {
/** /**
* @param {!number} size The diameter of the brush * @param {!number} size The diameter of the brush
* @param {!string} color The css color of the brush * @param {!string} color The css color of the brush
* @param {?boolean} isEraser True if we want the brush mark for the eraser
* @return {HTMLCanvasElement} a canvas with the brush mark printed on it * @return {HTMLCanvasElement} a canvas with the brush mark printed on it
*/ */
const getBrushMark = function (size, color) { const getBrushMark = function (size, color, isEraser) {
size = ~~size; size = ~~size;
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const roundedUpRadius = Math.ceil(size / 2); const roundedUpRadius = Math.ceil(size / 2);
@ -96,7 +97,8 @@ const getBrushMark = function (size, color) {
canvas.height = roundedUpRadius * 2; canvas.height = roundedUpRadius * 2;
const context = canvas.getContext('2d'); const context = canvas.getContext('2d');
context.imageSmoothingEnabled = false; context.imageSmoothingEnabled = false;
context.fillStyle = color; context.fillStyle = isEraser ? 'white' : color;
// @todo add outline for erasers
// Small squares for pixel artists // Small squares for pixel artists
if (size <= 5) { if (size <= 5) {
if (size % 2) { if (size % 2) {

View file

@ -4,6 +4,7 @@ const Modes = keyMirror({
BIT_BRUSH: null, BIT_BRUSH: null,
BIT_LINE: null, BIT_LINE: null,
BIT_RECT: null, BIT_RECT: null,
BIT_ERASER: null,
BRUSH: null, BRUSH: null,
ERASER: null, ERASER: null,
LINE: null, LINE: null,
@ -19,7 +20,8 @@ const Modes = keyMirror({
const BitmapModes = keyMirror({ const BitmapModes = keyMirror({
BIT_BRUSH: null, BIT_BRUSH: null,
BIT_LINE: null, BIT_LINE: null,
BIT_RECT: null BIT_RECT: null,
BIT_ERASER: null
}); });
export { export {

View file

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

View file

@ -1,6 +1,7 @@
import {combineReducers} from 'redux'; import {combineReducers} from 'redux';
import modeReducer from './modes'; import modeReducer from './modes';
import bitBrushSizeReducer from './bit-brush-size'; import bitBrushSizeReducer from './bit-brush-size';
import bitEraserSizeReducer from './bit-eraser-size';
import brushModeReducer from './brush-mode'; import brushModeReducer from './brush-mode';
import eraserModeReducer from './eraser-mode'; import eraserModeReducer from './eraser-mode';
import colorReducer from './color'; import colorReducer from './color';
@ -17,6 +18,7 @@ import undoReducer from './undo';
export default combineReducers({ export default combineReducers({
mode: modeReducer, mode: modeReducer,
bitBrushSize: bitBrushSizeReducer, bitBrushSize: bitBrushSizeReducer,
bitEraserSize: bitEraserSizeReducer,
brushMode: brushModeReducer, brushMode: brushModeReducer,
color: colorReducer, color: colorReducer,
clipboard: clipboardReducer, clipboard: clipboardReducer,