Add gradients to bitmap shape tools

This commit is contained in:
adroitwhiz 2020-06-09 15:02:36 -04:00
parent 9f77faf5c1
commit a304dea338
9 changed files with 179 additions and 113 deletions

View file

@ -4,9 +4,10 @@ import React from 'react';
import {connect} from 'react-redux';
import bindAll from 'lodash.bindall';
import Modes from '../lib/modes';
import ColorStyleProptype from '../lib/color-style-proptype';
import {MIXED} from '../helper/style-path';
import {changeFillColor, clearFillGradient, DEFAULT_COLOR} from '../reducers/fill-style';
import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-style';
import {changeMode} from '../reducers/modes';
import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items';
import {setCursor} from '../reducers/cursor';
@ -61,9 +62,8 @@ class BitOvalMode extends React.Component {
}
activateTool () {
clearSelection(this.props.clearSelectedItems);
this.props.clearGradient();
// Force the default brush color if fill is MIXED or transparent
const fillColorPresent = this.props.color !== MIXED && this.props.color !== null;
const fillColorPresent = this.props.color.primary !== MIXED && this.props.color.primary !== null;
if (!fillColorPresent) {
this.props.onChangeFillColor(DEFAULT_COLOR);
}
@ -94,9 +94,8 @@ class BitOvalMode extends React.Component {
}
BitOvalMode.propTypes = {
clearGradient: PropTypes.func.isRequired,
clearSelectedItems: PropTypes.func.isRequired,
color: PropTypes.string,
color: ColorStyleProptype,
filled: PropTypes.bool,
handleMouseDown: PropTypes.func.isRequired,
isOvalModeActive: PropTypes.bool.isRequired,
@ -110,7 +109,7 @@ BitOvalMode.propTypes = {
};
const mapStateToProps = state => ({
color: state.scratchPaint.color.fillColor.primary,
color: state.scratchPaint.color.fillColor,
filled: state.scratchPaint.fillBitmapShapes,
isOvalModeActive: state.scratchPaint.mode === Modes.BIT_OVAL,
selectedItems: state.scratchPaint.selectedItems,
@ -121,9 +120,6 @@ const mapDispatchToProps = dispatch => ({
clearSelectedItems: () => {
dispatch(clearSelectedItems());
},
clearGradient: () => {
dispatch(clearFillGradient());
},
setCursor: cursorString => {
dispatch(setCursor(cursorString));
},

View file

@ -4,9 +4,10 @@ import React from 'react';
import {connect} from 'react-redux';
import bindAll from 'lodash.bindall';
import Modes from '../lib/modes';
import ColorStyleProptype from '../lib/color-style-proptype';
import {MIXED} from '../helper/style-path';
import {changeFillColor, clearFillGradient, DEFAULT_COLOR} from '../reducers/fill-style';
import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-style';
import {changeMode} from '../reducers/modes';
import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items';
import {setCursor} from '../reducers/cursor';
@ -61,9 +62,8 @@ class BitRectMode extends React.Component {
}
activateTool () {
clearSelection(this.props.clearSelectedItems);
this.props.clearGradient();
// Force the default brush color if fill is MIXED or transparent
const fillColorPresent = this.props.color !== MIXED && this.props.color !== null;
const fillColorPresent = this.props.color.primary !== MIXED && this.props.color.primary !== null;
if (!fillColorPresent) {
this.props.onChangeFillColor(DEFAULT_COLOR);
}
@ -94,9 +94,8 @@ class BitRectMode extends React.Component {
}
BitRectMode.propTypes = {
clearGradient: PropTypes.func.isRequired,
clearSelectedItems: PropTypes.func.isRequired,
color: PropTypes.string,
color: ColorStyleProptype,
filled: PropTypes.bool,
handleMouseDown: PropTypes.func.isRequired,
isRectModeActive: PropTypes.bool.isRequired,
@ -110,7 +109,7 @@ BitRectMode.propTypes = {
};
const mapStateToProps = state => ({
color: state.scratchPaint.color.fillColor.primary,
color: state.scratchPaint.color.fillColor,
filled: state.scratchPaint.fillBitmapShapes,
isRectModeActive: state.scratchPaint.mode === Modes.BIT_RECT,
selectedItems: state.scratchPaint.selectedItems,
@ -121,9 +120,6 @@ const mapDispatchToProps = dispatch => ({
clearSelectedItems: () => {
dispatch(clearSelectedItems());
},
clearGradient: () => {
dispatch(clearFillGradient());
},
setCursor: cursorString => {
dispatch(setCursor(cursorString));
},

View file

@ -52,30 +52,34 @@ const makeColorIndicator = (label, isStroke) => {
}
}
const formatIsBitmap = isBitmap(this.props.format);
// Apply color and update redux, but do not update svg until picker closes.
const isDifferent = applyColorToSelection(
newColor,
this.props.colorIndex,
this.props.gradientType === GradientTypes.SOLID,
isBitmap(this.props.format),
isStroke,
formatIsBitmap,
// In bitmap mode, only the fill color selector is used, but it applies to stroke if fillBitmapShapes
// is set to true via the "Fill"/"Outline" selector button
isStroke || (formatIsBitmap && !this.props.fillBitmapShapes),
this.props.textEditTarget);
this._hasChanged = this._hasChanged || isDifferent;
this.props.onChangeColor(newColor, this.props.colorIndex);
}
handleChangeGradientType (gradientType) {
const formatIsBitmap = isBitmap(this.props.format);
// Apply color and update redux, but do not update svg until picker closes.
const isDifferent = applyGradientTypeToSelection(
gradientType,
isBitmap(this.props.format),
isStroke,
formatIsBitmap,
isStroke || (formatIsBitmap && !this.props.fillBitmapShapes),
this.props.textEditTarget);
this._hasChanged = this._hasChanged || isDifferent;
const hasSelectedItems = getSelectedLeafItems().length > 0;
if (hasSelectedItems) {
if (isDifferent) {
// Recalculates the swatch colors
this.props.setSelectedItems();
this.props.setSelectedItems(this.props.format);
}
}
if (this.props.gradientType === GradientTypes.SOLID && gradientType !== GradientTypes.SOLID) {
@ -100,11 +104,12 @@ const makeColorIndicator = (label, isStroke) => {
}
handleSwap () {
if (getSelectedLeafItems().length) {
const formatIsBitmap = isBitmap(this.props.format);
const isDifferent = swapColorsInSelection(
isBitmap(this.props.format),
isStroke,
formatIsBitmap,
isStroke || (formatIsBitmap && !this.props.fillBitmapShapes),
this.props.textEditTarget);
this.props.setSelectedItems();
this.props.setSelectedItems(this.props.format);
this._hasChanged = this._hasChanged || isDifferent;
} else {
let color1 = this.props.color;
@ -136,6 +141,7 @@ const makeColorIndicator = (label, isStroke) => {
color: PropTypes.string,
color2: PropTypes.string,
colorModalVisible: PropTypes.bool.isRequired,
fillBitmapShapes: PropTypes.bool.isRequired,
format: PropTypes.oneOf(Object.keys(Formats)),
gradientType: PropTypes.oneOf(Object.keys(GradientTypes)).isRequired,
intl: intlShape,

View file

@ -28,6 +28,7 @@ const mapStateToProps = state => ({
color: state.scratchPaint.color.fillColor.primary,
color2: state.scratchPaint.color.fillColor.secondary,
colorModalVisible: state.scratchPaint.modals.fillColor,
fillBitmapShapes: state.scratchPaint.fillBitmapShapes,
format: state.scratchPaint.format,
gradientType: state.scratchPaint.color.fillColor.gradientType,
isEyeDropping: state.scratchPaint.color.eyeDropper.active,
@ -38,6 +39,8 @@ const mapStateToProps = state => ({
state.scratchPaint.mode === Modes.RECT ||
state.scratchPaint.mode === Modes.OVAL ||
state.scratchPaint.mode === Modes.BIT_SELECT ||
state.scratchPaint.mode === Modes.BIT_RECT ||
state.scratchPaint.mode === Modes.BIT_OVAL ||
state.scratchPaint.mode === Modes.BIT_FILL,
textEditTarget: state.scratchPaint.textEditTarget
});

View file

@ -30,6 +30,7 @@ const mapStateToProps = state => ({
state.scratchPaint.mode === Modes.FILL,
color: state.scratchPaint.color.strokeColor.primary,
color2: state.scratchPaint.color.strokeColor.secondary,
fillBitmapShapes: state.scratchPaint.fillBitmapShapes,
colorModalVisible: state.scratchPaint.modals.strokeColor,
format: state.scratchPaint.format,
gradientType: state.scratchPaint.color.strokeColor.gradientType,
@ -38,7 +39,9 @@ const mapStateToProps = state => ({
shouldShowGradientTools: state.scratchPaint.mode === Modes.SELECT ||
state.scratchPaint.mode === Modes.RESHAPE ||
state.scratchPaint.mode === Modes.RECT ||
state.scratchPaint.mode === Modes.OVAL,
state.scratchPaint.mode === Modes.OVAL ||
state.scratchPaint.mode === Modes.BIT_RECT ||
state.scratchPaint.mode === Modes.BIT_OVAL,
textEditTarget: state.scratchPaint.textEditTarget
});

View file

@ -1,5 +1,6 @@
import paper from '@scratch/paper';
import Modes from '../../lib/modes';
import {styleShape} from '../style-path';
import {commitOvalToBitmap} from '../bitmap';
import {getRaster} from '../layer';
import {clearSelection} from '../selection';
@ -83,29 +84,22 @@ class OvalTool extends paper.Tool {
this.commitOval();
}
}
styleOval () {
styleShape(this.oval, {
fillColor: this.filled ? this.color : null,
strokeColor: this.filled ? null : this.color,
strokeWidth: this.filled ? 0 : this.thickness
});
}
setColor (color) {
this.color = color;
if (this.oval) {
if (this.filled) {
this.oval.fillColor = this.color;
} else {
this.oval.strokeColor = this.color;
}
}
if (this.oval) this.styleOval();
}
setFilled (filled) {
if (this.filled === filled) return;
this.filled = filled;
if (this.oval && this.oval.isInserted()) {
if (this.filled) {
this.oval.fillColor = this.color;
this.oval.strokeWidth = 0;
this.oval.strokeColor = null;
} else {
this.oval.fillColor = null;
this.oval.strokeWidth = this.thickness;
this.oval.strokeColor = this.color;
}
this.styleOval();
this.onUpdateImage();
}
}
@ -131,23 +125,12 @@ class OvalTool extends paper.Tool {
this.isBoundingBoxMode = false;
clearSelection(this.clearSelectedItems);
this.commitOval();
if (this.filled) {
this.oval = new paper.Shape.Ellipse({
fillColor: this.color,
point: event.downPoint,
strokeWidth: 0,
strokeScaling: false,
size: 0
size: 0,
strokeScaling: false
});
} else {
this.oval = new paper.Shape.Ellipse({
strokeColor: this.color,
strokeWidth: this.thickness,
point: event.downPoint,
strokeScaling: false,
size: 0
});
}
this.styleOval();
this.oval.data = {zoomLevel: paper.view.zoom};
}
}
@ -175,6 +158,7 @@ class OvalTool extends paper.Tool {
} else {
this.oval.position = downPoint.subtract(this.oval.size.multiply(0.5));
}
this.styleOval();
}
handleMouseMove (event) {
this.boundingBoxTool.onMouseMove(event, this.getHitOptions());
@ -197,6 +181,7 @@ class OvalTool extends paper.Tool {
// Hit testing does not work correctly unless the width and height are positive
this.oval.size = new paper.Point(Math.abs(this.oval.size.width), Math.abs(this.oval.size.height));
this.oval.selected = true;
this.styleOval();
this.setSelectedItems();
}
}

View file

@ -1,5 +1,6 @@
import paper from '@scratch/paper';
import Modes from '../../lib/modes';
import {styleShape} from '../../helper/style-path';
import {commitRectToBitmap} from '../bitmap';
import {getRaster} from '../layer';
import {clearSelection} from '../selection';
@ -81,29 +82,22 @@ class RectTool extends paper.Tool {
this.commitRect();
}
}
styleRect () {
styleShape(this.rect, {
fillColor: this.filled ? this.color : null,
strokeColor: this.filled ? null : this.color,
strokeWidth: this.filled ? 0 : this.thickness
});
}
setColor (color) {
this.color = color;
if (this.rect) {
if (this.filled) {
this.rect.fillColor = this.color;
} else {
this.rect.strokeColor = this.color;
}
}
if (this.rect) this.styleRect();
}
setFilled (filled) {
if (this.filled === filled) return;
this.filled = filled;
if (this.rect && this.rect.isInserted()) {
if (this.filled) {
this.rect.fillColor = this.color;
this.rect.strokeWidth = 0;
this.rect.strokeColor = null;
} else {
this.rect.fillColor = null;
this.rect.strokeWidth = this.thickness;
this.rect.strokeColor = this.color;
}
this.styleRect();
this.onUpdateImage();
}
}
@ -148,16 +142,10 @@ class RectTool extends paper.Tool {
if (this.rect) this.rect.remove();
this.rect = new paper.Shape.Rectangle(baseRect);
if (this.filled) {
this.rect.fillColor = this.color;
this.rect.strokeWidth = 0;
} else {
this.rect.strokeColor = this.color;
this.rect.strokeWidth = this.thickness;
}
this.rect.strokeJoin = 'round';
this.rect.strokeScaling = false;
this.rect.data = {zoomLevel: paper.view.zoom};
this.styleRect();
if (event.modifiers.alt) {
this.rect.position = event.downPoint;
@ -188,6 +176,7 @@ class RectTool extends paper.Tool {
// 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.styleRect();
this.setSelectedItems();
}
}

View file

@ -275,8 +275,24 @@ const drawEllipse = function (options, context) {
if (!matrix.isInvertible()) return false;
const inverse = matrix.clone().invert();
const isGradient = context.fillStyle instanceof CanvasGradient;
// If drawing a gradient, we need to draw the shape onto a temporary canvas, then draw the gradient atop that canvas
// only where the shape appears. drawShearedEllipse draws some pixels twice, which would be a problem if the
// gradient fades to transparent as those pixels would end up looking more opaque. Instead, mask in the gradient.
// https://github.com/LLK/scratch-paint/issues/1152
// Outlines are drawn as a series of brush mark images and as such can't be drawn as gradients in the first place.
let origContext;
let tmpCanvas;
const {width: canvasWidth, height: canvasHeight} = context.canvas;
if (isGradient) {
tmpCanvas = createCanvas(canvasWidth, canvasHeight);
origContext = context;
context = tmpCanvas.getContext('2d');
}
if (!isFilled) {
const brushMark = getBrushMark(thickness, context.fillStyle);
const brushMark = getBrushMark(thickness, isGradient ? 'black' : context.fillStyle);
const roundedUpRadius = Math.ceil(thickness / 2);
drawFn = (x, y) => {
context.drawImage(brushMark, ~~x - roundedUpRadius, ~~y - roundedUpRadius);
@ -295,7 +311,7 @@ const drawEllipse = function (options, context) {
const radiusA = Math.sqrt(-4 * C / ((B * B) - (4 * A * C)));
const slope = B / 2 / C;
return drawShearedEllipse_({
const wasDrawn = drawShearedEllipse_({
centerX: positionX,
centerY: positionY,
radiusX: radiusA,
@ -304,6 +320,17 @@ const drawEllipse = function (options, context) {
isFilled: isFilled,
drawFn: drawFn
}, context);
// Mask in the gradient only where the shape was drawn, and draw it. Then draw the gradientified shape onto the
// original canvas normally.
if (isGradient && wasDrawn) {
context.globalCompositeOperation = 'source-in';
context.fillStyle = origContext.fillStyle;
context.fillRect(0, 0, canvasWidth, canvasHeight);
origContext.drawImage(tmpCanvas, 0, 0);
}
return wasDrawn;
};
const rowBlank_ = function (imageData, width, y) {
@ -658,6 +685,20 @@ const outlineRect = function (rect, thickness, context) {
context.drawImage(brushMark, ~~x - roundedUpRadius, ~~y - roundedUpRadius);
};
const isGradient = context.fillStyle instanceof CanvasGradient;
// If drawing a gradient, we need to draw the shape onto a temporary canvas, then draw the gradient atop that canvas
// only where the shape appears. Outlines are drawn as a series of brush mark images and as such can't be drawn as
// gradients.
let origContext;
let tmpCanvas;
const {width: canvasWidth, height: canvasHeight} = context.canvas;
if (isGradient) {
tmpCanvas = createCanvas(canvasWidth, canvasHeight);
origContext = context;
context = tmpCanvas.getContext('2d');
}
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));
@ -667,6 +708,16 @@ const outlineRect = function (rect, thickness, context) {
forEachLinePoint(startPoint, heightPoint, drawFn);
forEachLinePoint(endPoint, widthPoint, drawFn);
forEachLinePoint(endPoint, heightPoint, drawFn);
// Mask in the gradient only where the shape was drawn, and draw it. Then draw the gradientified shape onto the
// original canvas normally.
if (isGradient) {
context.globalCompositeOperation = 'source-in';
context.fillStyle = origContext.fillStyle;
context.fillRect(0, 0, canvasWidth, canvasHeight);
origContext.drawImage(tmpCanvas, 0, 0);
}
};
const flipBitmapHorizontal = function (canvas) {
@ -773,6 +824,62 @@ const commitSelectionToBitmap = function (selection, bitmap) {
commitArbitraryTransformation_(selection, bitmap);
};
/**
* Converts a Paper.js color style (an item's fillColor or strokeColor) into a canvas-applicable color style.
* Note that a "color style" as applied to an item is different from a plain paper.Color or paper.Gradient.
* For instance, a gradient "color style" has origin and destination points whereas an unattached paper.Gradient
* does not.
* @param {paper.Color} color The color to convert to a canvas color/gradient
* @param {CanvasRenderingContext2D} context The rendering context on which the style will be used
* @returns {string|CanvasGradient} The canvas fill/stroke style.
*/
const _paperColorToCanvasStyle = function (color, context) {
if (!color) return null;
if (color.type === 'gradient') {
let canvasGradient;
const {origin, destination} = color;
if (color.gradient.radial) {
// Adapted from:
// https://github.com/paperjs/paper.js/blob/b081fd72c72cd61331313c3961edb48f3dfaffbd/src/style/Color.js#L926-L935
let {highlight} = color;
const start = highlight || origin;
const radius = destination.getDistance(origin);
if (highlight) {
const vector = highlight.subtract(origin);
if (vector.getLength() > radius) {
// Paper ¯\_(ツ)_/¯
highlight = origin.add(vector.normalize(radius - 0.1));
}
}
canvasGradient = context.createRadialGradient(
start.x, start.y,
0,
origin.x, origin.y,
radius
);
} else {
canvasGradient = context.createLinearGradient(
origin.x, origin.y,
destination.x, destination.y
);
}
const {stops} = color.gradient;
// Adapted from:
// https://github.com/paperjs/paper.js/blob/b081fd72c72cd61331313c3961edb48f3dfaffbd/src/style/Color.js#L940-L950
for (let i = 0, len = stops.length; i < len; i++) {
const stop = stops[i];
const offset = stop.offset;
canvasGradient.addColorStop(
offset || i / (len - 1),
stop.color.toCSS()
);
}
return canvasGradient;
}
return color.toCSS();
};
/**
* @param {paper.Shape.Ellipse} oval Vector oval to convert
* @param {paper.Raster} bitmap raster to draw selection
@ -784,12 +891,12 @@ const commitOvalToBitmap = function (oval, bitmap) {
const context = bitmap.getContext('2d');
const filled = oval.strokeWidth === 0;
const canvasColor = filled ? oval.fillColor : oval.strokeColor;
// If the color is null (e.g. fully transparent/"no fill"), don't bother drawing anything,
// and especially don't try calling `toCSS` on it
const canvasColor = _paperColorToCanvasStyle(filled ? oval.fillColor : oval.strokeColor, context);
// If the color is null (e.g. fully transparent/"no fill"), don't bother drawing anything
if (!canvasColor) return;
context.fillStyle = canvasColor.toCSS();
context.fillStyle = canvasColor;
const drew = drawEllipse({
position: oval.position,
radiusX,
@ -811,12 +918,12 @@ const commitRectToBitmap = function (rect, bitmap) {
const context = tmpCanvas.getContext('2d');
const filled = rect.strokeWidth === 0;
const canvasColor = filled ? rect.fillColor : rect.strokeColor;
// If the color is null (e.g. fully transparent/"no fill"), don't bother drawing anything,
// and especially don't try calling `toCSS` on it
const canvasColor = _paperColorToCanvasStyle(filled ? rect.fillColor : rect.strokeColor, context);
// If the color is null (e.g. fully transparent/"no fill"), don't bother drawing anything
if (!canvasColor) return;
context.fillStyle = canvasColor.toCSS();
context.fillStyle = canvasColor;
if (filled) {
fillRect(rect, context);
} else {

View file

@ -120,20 +120,6 @@ const applyColorToSelection = function (
item = item.parent;
}
// In bitmap mode, fill color applies to the stroke if there is a stroke
if (
bitmapMode &&
!applyToStroke &&
item.strokeColor !== null &&
item.strokeWidth
) {
if (!_colorMatch(item.strokeColor, colorString)) {
changed = true;
item.strokeColor = colorString;
}
continue;
}
const itemColorProp = applyToStroke ? 'strokeColor' : 'fillColor';
const itemColor = item[itemColorProp];
@ -179,8 +165,6 @@ const applyColorToSelection = function (
* @return {boolean} Whether the color application actually changed visibly.
*/
const swapColorsInSelection = function (bitmapMode, applyToStroke, textEditTargetId) {
if (bitmapMode) return; // @todo
const items = _getColorStateListeners(textEditTargetId);
let changed = false;
for (const item of items) {
@ -255,10 +239,7 @@ const applyGradientTypeToSelection = function (gradientType, bitmapMode, applyTo
itemColor2 = itemColor.gradient.stops[1].color.toCSS();
}
if (bitmapMode) {
// @todo Add when we apply gradients to selections in bitmap mode
continue;
} else if (gradientType === GradientTypes.SOLID) {
if (gradientType === GradientTypes.SOLID) {
if (itemColor && itemColor.gradient) {
changed = true;
item[itemColorProp] = itemColor1;