Bitmap rectangle tool (#494)

This commit is contained in:
DD Liu 2018-06-11 11:48:35 -04:00 committed by GitHub
parent 760ddabfce
commit 389eba6284
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 457 additions and 38 deletions

View file

@ -1,27 +1,26 @@
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 rectIcon from './rectangle.svg';
const BitRectComponent = () => (
<ComingSoonTooltip
place="right"
tooltipId="bit-rect-mode"
>
<ToolSelectComponent
disabled
imgDescriptor={{
defaultMessage: 'Rectangle',
description: 'Label for the rectangle tool',
id: 'paint.rectMode.rect'
}}
imgSrc={rectIcon}
isSelected={false}
onMouseDown={function () {}} // eslint-disable-line react/jsx-no-bind
/>
</ComingSoonTooltip>
const BitRectComponent = props => (
<ToolSelectComponent
imgDescriptor={{
defaultMessage: 'Rectangle',
description: 'Label for the rectangle tool',
id: 'paint.rectMode.rect'
}}
imgSrc={rectIcon}
isSelected={props.isSelected}
onMouseDown={props.onMouseDown}
/>
);
BitRectComponent.propTypes = {
isSelected: PropTypes.bool.isRequired,
onMouseDown: PropTypes.func.isRequired
};
export default BitRectComponent;

View file

@ -9,7 +9,7 @@ import PaperCanvas from '../../containers/paper-canvas.jsx';
import BitBrushMode from '../../containers/bit-brush-mode.jsx';
import BitLineMode from '../../containers/bit-line-mode.jsx';
import BitOvalMode from '../../components/bit-oval-mode/bit-oval-mode.jsx';
import BitRectMode from '../../components/bit-rect-mode/bit-rect-mode.jsx';
import BitRectMode from '../../containers/bit-rect-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 BitEraserMode from '../../components/bit-eraser-mode/bit-eraser-mode.jsx';
@ -177,7 +177,9 @@ const PaintEditorComponent = props => (
onUpdateImage={props.onUpdateImage}
/>
<BitOvalMode />
<BitRectMode />
<BitRectMode
onUpdateImage={props.onUpdateImage}
/>
<BitTextMode />
<BitFillMode />
<BitEraserMode />

View file

@ -0,0 +1,109 @@
import paper from '@scratch/paper';
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 {MIXED} from '../helper/style-path';
import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-color';
import {changeMode} from '../reducers/modes';
import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items';
import {clearSelection, getSelectedLeafItems} from '../helper/selection';
import RectTool from '../helper/bit-tools/rect-tool';
import RectModeComponent from '../components/bit-rect-mode/bit-rect-mode.jsx';
class BitRectMode extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'activateTool',
'deactivateTool'
]);
}
componentDidMount () {
if (this.props.isRectModeActive) {
this.activateTool(this.props);
}
}
componentWillReceiveProps (nextProps) {
if (this.tool && nextProps.color !== this.props.color) {
this.tool.setColor(nextProps.color);
}
if (this.tool && nextProps.selectedItems !== this.props.selectedItems) {
this.tool.onSelectionChanged(nextProps.selectedItems);
}
if (nextProps.isRectModeActive && !this.props.isRectModeActive) {
this.activateTool();
} else if (!nextProps.isRectModeActive && this.props.isRectModeActive) {
this.deactivateTool();
}
}
shouldComponentUpdate (nextProps) {
return nextProps.isRectModeActive !== this.props.isRectModeActive;
}
activateTool () {
clearSelection(this.props.clearSelectedItems);
// Force the default brush color if fill is MIXED or transparent
const fillColorPresent = this.props.color !== MIXED && this.props.color !== null;
if (!fillColorPresent) {
this.props.onChangeFillColor(DEFAULT_COLOR);
}
this.tool = new RectTool(
this.props.setSelectedItems,
this.props.clearSelectedItems,
this.props.onUpdateImage);
this.tool.setColor(this.props.color);
this.tool.activate();
}
deactivateTool () {
this.tool.deactivateTool();
this.tool.remove();
this.tool = null;
}
render () {
return (
<RectModeComponent
isSelected={this.props.isRectModeActive}
onMouseDown={this.props.handleMouseDown}
/>
);
}
}
BitRectMode.propTypes = {
clearSelectedItems: PropTypes.func.isRequired,
color: PropTypes.string,
handleMouseDown: PropTypes.func.isRequired,
isRectModeActive: PropTypes.bool.isRequired,
onChangeFillColor: PropTypes.func.isRequired,
onUpdateImage: PropTypes.func.isRequired,
selectedItems: PropTypes.arrayOf(PropTypes.instanceOf(paper.Item)),
setSelectedItems: PropTypes.func.isRequired
};
const mapStateToProps = state => ({
color: state.scratchPaint.color.fillColor,
isRectModeActive: state.scratchPaint.mode === Modes.BIT_RECT,
selectedItems: state.scratchPaint.selectedItems
});
const mapDispatchToProps = dispatch => ({
clearSelectedItems: () => {
dispatch(clearSelectedItems());
},
setSelectedItems: () => {
dispatch(setSelectedItems(getSelectedLeafItems()));
},
handleMouseDown: () => {
dispatch(changeMode(Modes.BIT_RECT));
},
onChangeFillColor: fillColor => {
dispatch(changeFillColor(fillColor));
}
});
export default connect(
mapStateToProps,
mapDispatchToProps
)(BitRectMode);

View file

@ -2,6 +2,7 @@ import paper from '@scratch/paper';
import PropTypes from 'prop-types';
import React from 'react';
import {connect} from 'react-redux';
import PaintEditorComponent from '../components/paint-editor/paint-editor.jsx';
import {changeMode} from '../reducers/modes';
@ -13,7 +14,7 @@ import {setTextEditTarget} from '../reducers/text-edit-target';
import {updateViewBounds} from '../reducers/view-bounds';
import {getRaster, hideGuideLayers, showGuideLayers} from '../helper/layer';
import {getHitBounds} from '../helper/bitmap';
import {convertToBitmap, convertToVector, getHitBounds} from '../helper/bitmap';
import {performUndo, performRedo, performSnapshot, shouldShowUndo, shouldShowRedo} from '../helper/undo';
import {bringToFront, sendBackward, sendToBack, bringForward} from '../helper/order';
import {groupSelection, ungroupSelection} from '../helper/group';
@ -24,9 +25,9 @@ import {resetZoom, zoomOnSelection} from '../helper/view';
import EyeDropperTool from '../helper/tools/eye-dropper';
import Modes from '../lib/modes';
import {BitmapModes} from '../lib/modes';
import Formats from '../lib/format';
import {isBitmap, isVector} from '../lib/format';
import {connect} from 'react-redux';
import bindAll from 'lodash.bindall';
class PaintEditor extends React.Component {
@ -61,6 +62,9 @@ class PaintEditor extends React.Component {
canvas: null,
colorInfo: null
};
// When isSwitchingFormats is true, the format is about to switch, but isn't done switching.
// This gives currently active tools a chance to finish what they were doing.
this.isSwitchingFormats = false;
}
componentDidMount () {
document.addEventListener('keydown', (/* event */) => {
@ -76,6 +80,17 @@ class PaintEditor extends React.Component {
document.addEventListener('mousedown', this.onMouseDown);
document.addEventListener('touchstart', this.onMouseDown);
}
componentWillReceiveProps (newProps) {
if ((isVector(this.props.format) && newProps.format === Formats.BITMAP) ||
(isBitmap(this.props.format) && newProps.format === Formats.VECTOR)) {
this.isSwitchingFormats = true;
}
if (isVector(this.props.format) && isBitmap(newProps.format)) {
this.switchMode(Formats.BITMAP);
} else if (isVector(newProps.format) && isBitmap(this.props.format)) {
this.switchMode(Formats.VECTOR);
}
}
componentDidUpdate (prevProps) {
if (this.props.isEyeDropping && !prevProps.isEyeDropping) {
this.startEyeDroppingLoop();
@ -83,9 +98,12 @@ class PaintEditor extends React.Component {
this.stopEyeDroppingLoop();
}
if ((isVector(this.props.format) && isBitmap(prevProps.format)) ||
(isVector(prevProps.format) && isBitmap(this.props.format))) {
this.switchMode(this.props.format);
if (this.props.format === Formats.VECTOR && isBitmap(prevProps.format)) {
this.isSwitchingFormats = false;
convertToVector(this.props.clearSelectedItems, this.handleUpdateImage);
} else if (isVector(prevProps.format) && this.props.format === Formats.BITMAP) {
this.isSwitchingFormats = false;
convertToBitmap(this.props.clearSelectedItems, this.handleUpdateImage);
}
}
componentWillUnmount () {
@ -103,6 +121,9 @@ class PaintEditor extends React.Component {
case Modes.BIT_LINE:
this.props.changeMode(Modes.LINE);
break;
case Modes.BIT_RECT:
this.props.changeMode(Modes.RECT);
break;
default:
this.props.changeMode(Modes.BRUSH);
}
@ -114,20 +135,28 @@ class PaintEditor extends React.Component {
case Modes.LINE:
this.props.changeMode(Modes.BIT_LINE);
break;
case Modes.RECT:
this.props.changeMode(Modes.BIT_RECT);
break;
default:
this.props.changeMode(Modes.BIT_BRUSH);
}
}
}
handleUpdateImage (skipSnapshot) {
if (isBitmap(this.props.format)) {
// If in the middle of switching formats, rely on the current mode instead of format.
let actualFormat = this.props.format;
if (this.isSwitchingFormats) {
actualFormat = BitmapModes[this.props.mode] ? Formats.BITMAP : Formats.VECTOR;
}
if (isBitmap(actualFormat)) {
const rect = getHitBounds(getRaster());
this.props.onUpdateImage(
false /* isVector */,
getRaster().getImageData(rect),
(ART_BOARD_WIDTH / 2) - rect.x,
(ART_BOARD_HEIGHT / 2) - rect.y);
} else if (isVector(this.props.format)) {
} else if (isVector(actualFormat)) {
const guideLayers = hideGuideLayers(true /* includeRaster */);
// Export at 0.5x
@ -150,7 +179,7 @@ class PaintEditor extends React.Component {
}
if (!skipSnapshot) {
performSnapshot(this.props.undoSnapshot, this.props.format);
performSnapshot(this.props.undoSnapshot, actualFormat);
}
}
handleUndo () {

View file

@ -7,7 +7,6 @@ import Formats from '../lib/format';
import Modes from '../lib/modes';
import log from '../log/log';
import {convertToBitmap, convertToVector} from '../helper/bitmap';
import {performSnapshot} from '../helper/undo';
import {undoSnapshot, clearUndoState} from '../reducers/undo';
import {isGroup, ungroupItems} from '../helper/group';
@ -21,8 +20,6 @@ import {clearPasteOffset} from '../reducers/clipboard';
import {updateViewBounds} from '../reducers/view-bounds';
import {changeFormat} from '../reducers/format';
import {isVector, isBitmap} from '../lib/format';
import styles from './paper-canvas.css';
class PaperCanvas extends React.Component {
@ -56,10 +53,6 @@ class PaperCanvas extends React.Component {
if (this.props.imageId !== newProps.imageId) {
this.switchCostume(
newProps.imageFormat, newProps.image, newProps.rotationCenterX, newProps.rotationCenterY);
} else if (isVector(this.props.format) && newProps.format === Formats.BITMAP) {
convertToBitmap(this.props.clearSelectedItems, this.props.onUpdateImage);
} else if (isBitmap(this.props.format) && newProps.format === Formats.VECTOR) {
convertToVector(this.props.clearSelectedItems, this.props.onUpdateImage);
}
}
componentWillUnmount () {
@ -260,12 +253,11 @@ PaperCanvas.propTypes = {
clearPasteOffset: PropTypes.func.isRequired,
clearSelectedItems: PropTypes.func.isRequired,
clearUndo: PropTypes.func.isRequired,
format: PropTypes.oneOf(Object.keys(Formats)), // Internal, up-to-date data format
image: PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(HTMLImageElement)
]),
imageFormat: PropTypes.string, // The incoming image's data format, used during import
imageFormat: PropTypes.string, // The incoming image's data format, used during import. The user could switch this.
imageId: PropTypes.string,
mode: PropTypes.oneOf(Object.keys(Modes)),
onUpdateImage: PropTypes.func.isRequired,

View file

@ -0,0 +1,152 @@
import paper from '@scratch/paper';
import Modes from '../../lib/modes';
import {drawRect} from '../bitmap';
import {getRaster} from '../layer';
import {clearSelection} from '../selection';
import BoundingBoxTool from '../selection-tools/bounding-box-tool';
import NudgeTool from '../selection-tools/nudge-tool';
/**
* Tool for drawing rects.
*/
class RectTool extends paper.Tool {
static get TOLERANCE () {
return 6;
}
/**
* @param {function} setSelectedItems Callback to set the set of selected items in the Redux state
* @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state
* @param {!function} onUpdateImage A callback to call when the image visibly changes
*/
constructor (setSelectedItems, clearSelectedItems, onUpdateImage) {
super();
this.setSelectedItems = setSelectedItems;
this.clearSelectedItems = clearSelectedItems;
this.onUpdateImage = onUpdateImage;
this.boundingBoxTool = new BoundingBoxTool(Modes.BIT_RECT, setSelectedItems, clearSelectedItems, onUpdateImage);
const nudgeTool = new NudgeTool(this.boundingBoxTool, onUpdateImage);
// We have to set these functions instead of just declaring them because
// paper.js tools hook up the listeners in the setter functions.
this.onMouseDown = this.handleMouseDown;
this.onMouseDrag = this.handleMouseDrag;
this.onMouseUp = this.handleMouseUp;
this.onKeyUp = nudgeTool.onKeyUp;
this.onKeyDown = nudgeTool.onKeyDown;
this.rect = null;
this.color = null;
this.active = false;
}
getHitOptions () {
return {
segments: false,
stroke: true,
curves: false,
fill: true,
guide: false,
match: hitResult =>
(hitResult.item.data && hitResult.item.data.isHelperItem) ||
hitResult.item === this.rect, // Allow hits on bounding box and rect only
tolerance: RectTool.TOLERANCE / paper.view.zoom
};
}
/**
* Should be called if the selection changes to update the bounds of the bounding box.
* @param {Array<paper.Item>} selectedItems Array of selected items.
*/
onSelectionChanged (selectedItems) {
this.boundingBoxTool.onSelectionChanged(selectedItems);
if ((!this.rect || !this.rect.parent) &&
selectedItems && selectedItems.length === 1 && selectedItems[0].shape === 'rectangle') {
// Infer that an undo occurred and get back the active rect
this.rect = selectedItems[0];
} else if (this.rect && this.rect.parent && !this.rect.selected) {
// Rectangle got deselected
this.commitRect();
}
}
setColor (color) {
this.color = color;
}
handleMouseDown (event) {
if (event.event.button > 0) return; // only first mouse button
this.active = true;
if (this.boundingBoxTool.onMouseDown(event, false /* clone */, false /* multiselect */, this.getHitOptions())) {
this.isBoundingBoxMode = true;
} else {
this.isBoundingBoxMode = false;
clearSelection(this.clearSelectedItems);
this.commitRect();
}
}
handleMouseDrag (event) {
if (event.event.button > 0 || !this.active) return; // only first mouse button
if (this.isBoundingBoxMode) {
this.boundingBoxTool.onMouseDrag(event);
return;
}
const dimensions = event.point.subtract(event.downPoint);
const baseRect = new paper.Rectangle(event.downPoint, event.point);
if (event.modifiers.shift) {
baseRect.height = baseRect.width;
dimensions.y = event.downPoint.y > event.point.y ? -Math.abs(baseRect.width) : Math.abs(baseRect.width);
}
if (this.rect) this.rect.remove();
this.rect = new paper.Shape.Rectangle(baseRect);
this.rect.fillColor = this.color;
if (event.modifiers.alt) {
this.rect.position = event.downPoint;
} else {
this.rect.position = event.downPoint.add(dimensions.multiply(.5));
}
}
handleMouseUp (event) {
if (event.event.button > 0 || !this.active) return; // only first mouse button
if (this.isBoundingBoxMode) {
this.boundingBoxTool.onMouseUp(event);
this.isBoundingBoxMode = null;
return;
}
if (this.rect) {
if (Math.abs(this.rect.size.width * this.rect.size.height) < RectTool.TOLERANCE / paper.view.zoom) {
// Tiny shape created unintentionally?
this.rect.remove();
this.rect = null;
} else {
// Hit testing does not work correctly unless the width and height are positive
this.rect.size = new paper.Point(Math.abs(this.rect.size.width), Math.abs(this.rect.size.height));
this.rect.selected = true;
this.setSelectedItems();
}
}
this.active = false;
}
commitRect () {
if (!this.rect || !this.rect.parent) return;
const tmpCanvas = document.createElement('canvas');
tmpCanvas.width = getRaster().width;
tmpCanvas.height = getRaster().height;
const context = tmpCanvas.getContext('2d');
context.fillStyle = this.color;
drawRect(this.rect, context);
getRaster().drawImage(tmpCanvas, new paper.Point());
this.rect.remove();
this.rect = null;
this.onUpdateImage();
}
deactivateTool () {
this.commitRect();
this.boundingBoxTool.removeBoundsPath();
}
}
export default RectTool;

View file

@ -209,9 +209,135 @@ const convertToVector = function (clearSelectedItems, onUpdateImage) {
onUpdateImage();
};
const getColor_ = function (x, y, context) {
return context.getImageData(x, y, 1, 1).data;
};
const matchesColor_ = function (x, y, imageData, oldColor) {
const index = ((y * imageData.width) + x) * 4;
return (
imageData.data[index + 0] === oldColor[0] &&
imageData.data[index + 1] === oldColor[1] &&
imageData.data[index + 2] === oldColor[2] &&
imageData.data[index + 3 ] === oldColor[3]
);
};
const colorPixel_ = function (x, y, imageData, newColor) {
const index = ((y * imageData.width) + x) * 4;
imageData.data[index + 0] = newColor[0];
imageData.data[index + 1] = newColor[1];
imageData.data[index + 2] = newColor[2];
imageData.data[index + 3] = newColor[3];
};
/**
* Flood fill beginning at the given point.
* Based on http://www.williammalone.com/articles/html5-canvas-javascript-paint-bucket-tool/
*
* @param {!int} x The x coordinate on the context at which to begin
* @param {!int} y The y coordinate on the context at which to begin
* @param {!ImageData} imageData The image data to edit
* @param {!Array<number>} newColor The color to replace with. A length 4 array [r, g, b, a].
* @param {!Array<number>} oldColor The color to replace. A length 4 array [r, g, b, a].
* This must be different from newColor.
* @param {!Array<Array<int>>} stack The stack of pixels we need to look at
*/
const floodFillInternal_ = function (x, y, imageData, newColor, oldColor, stack) {
while (y > 0 && matchesColor_(x, y - 1, imageData, oldColor)) {
y--;
}
let lastLeftMatchedColor = false;
let lastRightMatchedColor = false;
for (; y < imageData.height; y++) {
if (!matchesColor_(x, y, imageData, oldColor)) break;
colorPixel_(x, y, imageData, newColor);
if (x > 0) {
if (matchesColor_(x - 1, y, imageData, oldColor)) {
if (!lastLeftMatchedColor) {
stack.push([x - 1, y]);
lastLeftMatchedColor = true;
}
} else {
lastLeftMatchedColor = false;
}
}
if (x < imageData.width - 1) {
if (matchesColor_(x + 1, y, imageData, oldColor)) {
if (!lastRightMatchedColor) {
stack.push([x + 1, y]);
lastRightMatchedColor = true;
}
} else {
lastRightMatchedColor = false;
}
}
}
};
/**
* Flood fill beginning at the given point
* @param {!int} x The x coordinate on the context at which to begin
* @param {!int} y The y coordinate on the context at which to begin
* @param {!HTMLCanvas2DContext} context The context in which to draw
*/
const floodFill = function (x, y, context) {
const oldColor = getColor_(x, y, context);
context.fillRect(x, y, 1, 1);
const newColor = getColor_(x, y, context);
const imageData = context.getImageData(0, 0, context.canvas.width, context.canvas.height);
if (matchesColor_(x, y, imageData, oldColor)) { // no-op
return;
}
colorPixel_(x, y, imageData, newColor); // Restore old color to avoid affecting result
const stack = [[x, y]];
while (stack.length) {
const pop = stack.pop();
floodFillInternal_(pop[0], pop[1], imageData, newColor, oldColor, stack);
}
context.putImageData(imageData, 0, 0);
};
/**
* @param {!paper.Shape.Rectangle} rect The rectangle to draw to the canvas
* @param {!HTMLCanvas2DContext} context The context in which to draw
*/
const drawRect = function (rect, context) {
// No rotation component to matrix
if (rect.matrix.b === 0 && rect.matrix.c === 0) {
const width = rect.size.width * rect.matrix.a;
const height = rect.size.height * rect.matrix.d;
context.fillRect(
~~(rect.matrix.tx - (width / 2)),
~~(rect.matrix.ty - (height / 2)),
~~width,
~~height);
return;
}
const startPoint = rect.matrix.transform(new paper.Point(-rect.size.width / 2, -rect.size.height / 2));
const widthPoint = rect.matrix.transform(new paper.Point(rect.size.width / 2, -rect.size.height / 2));
const heightPoint = rect.matrix.transform(new paper.Point(-rect.size.width / 2, rect.size.height / 2));
const endPoint = rect.matrix.transform(new paper.Point(rect.size.width / 2, rect.size.height / 2));
const center = rect.matrix.transform(new paper.Point());
forEachLinePoint(startPoint, widthPoint, (x, y) => {
context.fillRect(x, y, 1, 1);
});
forEachLinePoint(startPoint, heightPoint, (x, y) => {
context.fillRect(x, y, 1, 1);
});
forEachLinePoint(endPoint, widthPoint, (x, y) => {
context.fillRect(x, y, 1, 1);
});
forEachLinePoint(endPoint, heightPoint, (x, y) => {
context.fillRect(x, y, 1, 1);
});
floodFill(~~center.x, ~~center.y, context);
};
export {
convertToBitmap,
convertToVector,
drawRect,
getBrushMark,
getHitBounds,
fillEllipse,

View file

@ -3,6 +3,7 @@ import keyMirror from 'keymirror';
const Modes = keyMirror({
BIT_BRUSH: null,
BIT_LINE: null,
BIT_RECT: null,
BRUSH: null,
ERASER: null,
LINE: null,
@ -15,4 +16,13 @@ const Modes = keyMirror({
TEXT: null
});
export default Modes;
const BitmapModes = keyMirror({
BIT_BRUSH: null,
BIT_LINE: null,
BIT_RECT: null
});
export {
Modes as default,
BitmapModes
};