diff --git a/src/components/fill-color-indicator.jsx b/src/components/color-indicator.jsx similarity index 53% rename from src/components/fill-color-indicator.jsx rename to src/components/color-indicator.jsx index 0a0fa0cf05..d04ea2bbf2 100644 --- a/src/components/fill-color-indicator.jsx +++ b/src/components/color-indicator.jsx @@ -1,7 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; import Popover from 'react-popover'; -import {defineMessages, injectIntl, intlShape} from 'react-intl'; import ColorButton from './color-button/color-button.jsx'; import ColorPicker from '../containers/color-picker.jsx'; @@ -10,15 +9,7 @@ import Label from './forms/label.jsx'; import GradientTypes from '../lib/gradient-types'; -const messages = defineMessages({ - fill: { - id: 'paint.paintEditor.fill', - description: 'Label for the color picker for the fill color', - defaultMessage: 'Fill' - } -}); - -const FillColorIndicatorComponent = props => ( +const ColorIndicatorComponent = props => ( ( } - isOpen={props.fillColorModalVisible} + isOpen={props.colorModalVisible} preferPlace="below" - onOuterAction={props.onCloseFillColor} + onOuterAction={props.onCloseColor} > - ); -FillColorIndicatorComponent.propTypes = { +ColorIndicatorComponent.propTypes = { className: PropTypes.string, disabled: PropTypes.bool.isRequired, - fillColor: PropTypes.string, - fillColor2: PropTypes.string, - fillColorModalVisible: PropTypes.bool.isRequired, + color: PropTypes.string, + color2: PropTypes.string, + colorModalVisible: PropTypes.bool.isRequired, gradientType: PropTypes.oneOf(Object.keys(GradientTypes)).isRequired, - intl: intlShape, - onChangeFillColor: PropTypes.func.isRequired, + label: PropTypes.string.isRequired, + onChangeColor: PropTypes.func.isRequired, onChangeGradientType: PropTypes.func.isRequired, - onCloseFillColor: PropTypes.func.isRequired, - onOpenFillColor: PropTypes.func.isRequired, + onCloseColor: PropTypes.func.isRequired, + onOpenColor: PropTypes.func.isRequired, onSwap: PropTypes.func.isRequired, + outline: PropTypes.bool.isRequired, shouldShowGradientTools: PropTypes.bool.isRequired }; -export default injectIntl(FillColorIndicatorComponent); +export default ColorIndicatorComponent; diff --git a/src/components/stroke-color-indicator.jsx b/src/components/stroke-color-indicator.jsx deleted file mode 100644 index 15c40d9bd7..0000000000 --- a/src/components/stroke-color-indicator.jsx +++ /dev/null @@ -1,65 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Popover from 'react-popover'; -import {defineMessages, injectIntl, intlShape} from 'react-intl'; - -import ColorButton from './color-button/color-button.jsx'; -import ColorPicker from '../containers/color-picker.jsx'; -import InputGroup from './input-group/input-group.jsx'; -import Label from './forms/label.jsx'; -import GradientTypes from '../lib/gradient-types'; - -const messages = defineMessages({ - stroke: { - id: 'paint.paintEditor.stroke', - description: 'Label for the color picker for the outline color', - defaultMessage: 'Outline' - } -}); - -const StrokeColorIndicatorComponent = props => ( - - - } - isOpen={props.strokeColorModalVisible} - preferPlace="below" - onOuterAction={props.onCloseStrokeColor} - > - - - -); - -StrokeColorIndicatorComponent.propTypes = { - className: PropTypes.string, - disabled: PropTypes.bool.isRequired, - intl: intlShape, - onChangeStrokeColor: PropTypes.func.isRequired, - onCloseStrokeColor: PropTypes.func.isRequired, - onOpenStrokeColor: PropTypes.func.isRequired, - strokeColor: PropTypes.string, - strokeColorModalVisible: PropTypes.bool.isRequired -}; - -export default injectIntl(StrokeColorIndicatorComponent); diff --git a/src/containers/bit-brush-mode.jsx b/src/containers/bit-brush-mode.jsx index 0697821625..652395d8eb 100644 --- a/src/containers/bit-brush-mode.jsx +++ b/src/containers/bit-brush-mode.jsx @@ -5,11 +5,10 @@ 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 {changeFillColor, clearFillGradient, DEFAULT_COLOR} from '../reducers/fill-style'; import {changeMode} from '../reducers/modes'; import {clearSelectedItems} from '../reducers/selected-items'; import {clearSelection} from '../helper/selection'; -import {clearGradient} from '../reducers/selection-gradient-type'; import BitBrushModeComponent from '../components/bit-brush-mode/bit-brush-mode.jsx'; import BitBrushTool from '../helper/bit-tools/brush-tool'; @@ -94,7 +93,7 @@ BitBrushMode.propTypes = { const mapStateToProps = state => ({ bitBrushSize: state.scratchPaint.bitBrushSize, - color: state.scratchPaint.color.fillColor, + color: state.scratchPaint.color.fillColor.primary, isBitBrushModeActive: state.scratchPaint.mode === Modes.BIT_BRUSH }); const mapDispatchToProps = dispatch => ({ @@ -102,7 +101,7 @@ const mapDispatchToProps = dispatch => ({ dispatch(clearSelectedItems()); }, clearGradient: () => { - dispatch(clearGradient()); + dispatch(clearFillGradient()); }, handleMouseDown: () => { dispatch(changeMode(Modes.BIT_BRUSH)); diff --git a/src/containers/bit-fill-mode.jsx b/src/containers/bit-fill-mode.jsx index 4987bae8aa..85dbd11881 100644 --- a/src/containers/bit-fill-mode.jsx +++ b/src/containers/bit-fill-mode.jsx @@ -7,8 +7,7 @@ import GradientTypes from '../lib/gradient-types'; import FillModeComponent from '../components/bit-fill-mode/bit-fill-mode.jsx'; -import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-color'; -import {changeFillColor2} from '../reducers/fill-color-2'; +import {changeFillColor, changeFillColor2, DEFAULT_COLOR} from '../reducers/fill-style'; import {changeMode} from '../reducers/modes'; import {clearSelectedItems} from '../reducers/selected-items'; import {changeGradientType} from '../reducers/fill-mode-gradient-type'; @@ -66,10 +65,10 @@ class BitFillMode extends React.Component { this.props.onChangeFillColor(DEFAULT_COLOR, 0); } const gradientType = this.props.fillModeGradientType ? - this.props.fillModeGradientType : this.props.selectModeGradientType; + this.props.fillModeGradientType : this.props.styleGradientType; let color2 = this.props.color2; - if (gradientType !== this.props.selectModeGradientType) { - if (this.props.selectModeGradientType === GradientTypes.SOLID) { + if (gradientType !== this.props.styleGradientType) { + if (this.props.styleGradientType === GradientTypes.SOLID) { color2 = getRotatedColor(color); this.props.onChangeFillColor(color2, 1); } @@ -105,20 +104,20 @@ BitFillMode.propTypes = { clearSelectedItems: PropTypes.func.isRequired, color: PropTypes.string, color2: PropTypes.string, + styleGradientType: PropTypes.oneOf(Object.keys(GradientTypes)).isRequired, fillModeGradientType: PropTypes.oneOf(Object.keys(GradientTypes)), handleMouseDown: PropTypes.func.isRequired, isFillModeActive: PropTypes.bool.isRequired, onChangeFillColor: PropTypes.func.isRequired, - onUpdateImage: PropTypes.func.isRequired, - selectModeGradientType: PropTypes.oneOf(Object.keys(GradientTypes)).isRequired + onUpdateImage: PropTypes.func.isRequired }; const mapStateToProps = state => ({ fillModeGradientType: state.scratchPaint.fillMode.gradientType, // Last user-selected gradient type - color: state.scratchPaint.color.fillColor, - color2: state.scratchPaint.color.fillColor2, - isFillModeActive: state.scratchPaint.mode === Modes.BIT_FILL, - selectModeGradientType: state.scratchPaint.color.gradientType + color: state.scratchPaint.color.fillColor.primary, + color2: state.scratchPaint.color.fillColor.secondary, + styleGradientType: state.scratchPaint.color.fillColor.gradientType, + isFillModeActive: state.scratchPaint.mode === Modes.BIT_FILL }); const mapDispatchToProps = dispatch => ({ clearSelectedItems: () => { diff --git a/src/containers/bit-line-mode.jsx b/src/containers/bit-line-mode.jsx index c2a9e9bf59..5b57cdafb1 100644 --- a/src/containers/bit-line-mode.jsx +++ b/src/containers/bit-line-mode.jsx @@ -5,11 +5,10 @@ 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 {changeFillColor, clearFillGradient, DEFAULT_COLOR} from '../reducers/fill-style'; import {changeMode} from '../reducers/modes'; import {clearSelectedItems} from '../reducers/selected-items'; import {clearSelection} from '../helper/selection'; -import {clearGradient} from '../reducers/selection-gradient-type'; import BitLineModeComponent from '../components/bit-line-mode/bit-line-mode.jsx'; import BitLineTool from '../helper/bit-tools/line-tool'; @@ -94,7 +93,7 @@ BitLineMode.propTypes = { const mapStateToProps = state => ({ bitBrushSize: state.scratchPaint.bitBrushSize, - color: state.scratchPaint.color.fillColor, + color: state.scratchPaint.color.fillColor.primary, isBitLineModeActive: state.scratchPaint.mode === Modes.BIT_LINE }); const mapDispatchToProps = dispatch => ({ @@ -102,7 +101,7 @@ const mapDispatchToProps = dispatch => ({ dispatch(clearSelectedItems()); }, clearGradient: () => { - dispatch(clearGradient()); + dispatch(clearFillGradient()); }, handleMouseDown: () => { dispatch(changeMode(Modes.BIT_LINE)); diff --git a/src/containers/bit-oval-mode.jsx b/src/containers/bit-oval-mode.jsx index 0a7f356bf9..bf3ae380c1 100644 --- a/src/containers/bit-oval-mode.jsx +++ b/src/containers/bit-oval-mode.jsx @@ -4,12 +4,12 @@ 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, DEFAULT_COLOR} from '../reducers/fill-color'; +import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-style'; import {changeMode} from '../reducers/modes'; import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; -import {clearGradient} from '../reducers/selection-gradient-type'; import {setCursor} from '../reducers/cursor'; import {clearSelection, getSelectedLeafItems} from '../helper/selection'; @@ -62,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); } @@ -95,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, @@ -122,9 +120,6 @@ const mapDispatchToProps = dispatch => ({ clearSelectedItems: () => { dispatch(clearSelectedItems()); }, - clearGradient: () => { - dispatch(clearGradient()); - }, setCursor: cursorString => { dispatch(setCursor(cursorString)); }, diff --git a/src/containers/bit-rect-mode.jsx b/src/containers/bit-rect-mode.jsx index 97c1f5cb98..6263953a6b 100644 --- a/src/containers/bit-rect-mode.jsx +++ b/src/containers/bit-rect-mode.jsx @@ -4,12 +4,12 @@ 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, DEFAULT_COLOR} from '../reducers/fill-color'; +import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-style'; import {changeMode} from '../reducers/modes'; import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; -import {clearGradient} from '../reducers/selection-gradient-type'; import {setCursor} from '../reducers/cursor'; import {clearSelection, getSelectedLeafItems} from '../helper/selection'; @@ -62,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); } @@ -95,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, @@ -122,9 +120,6 @@ const mapDispatchToProps = dispatch => ({ clearSelectedItems: () => { dispatch(clearSelectedItems()); }, - clearGradient: () => { - dispatch(clearGradient()); - }, setCursor: cursorString => { dispatch(setCursor(cursorString)); }, diff --git a/src/containers/bit-select-mode.jsx b/src/containers/bit-select-mode.jsx index 189799f5fa..7f049b1416 100644 --- a/src/containers/bit-select-mode.jsx +++ b/src/containers/bit-select-mode.jsx @@ -5,9 +5,9 @@ import {connect} from 'react-redux'; import bindAll from 'lodash.bindall'; import Modes from '../lib/modes'; +import {clearFillGradient} from '../reducers/fill-style'; import {changeMode} from '../reducers/modes'; import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; -import {clearGradient} from '../reducers/selection-gradient-type'; import {setCursor} from '../reducers/cursor'; import {getSelectedLeafItems} from '../helper/selection'; @@ -88,7 +88,7 @@ const mapStateToProps = state => ({ }); const mapDispatchToProps = dispatch => ({ clearGradient: () => { - dispatch(clearGradient()); + dispatch(clearFillGradient()); }, clearSelectedItems: () => { dispatch(clearSelectedItems()); diff --git a/src/containers/brush-mode.jsx b/src/containers/brush-mode.jsx index f47a71a96b..f1936ad617 100644 --- a/src/containers/brush-mode.jsx +++ b/src/containers/brush-mode.jsx @@ -3,13 +3,13 @@ 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 Blobbiness from '../helper/blob-tools/blob'; import {MIXED} from '../helper/style-path'; -import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-color'; +import {changeFillColor, clearFillGradient, DEFAULT_COLOR} from '../reducers/fill-style'; import {changeMode} from '../reducers/modes'; import {clearSelectedItems} from '../reducers/selected-items'; -import {clearGradient} from '../reducers/selection-gradient-type'; import {clearSelection} from '../helper/selection'; import BrushModeComponent from '../components/brush-mode/brush-mode.jsx'; @@ -35,9 +35,12 @@ class BrushMode extends React.Component { } else if (!nextProps.isBrushModeActive && this.props.isBrushModeActive) { this.deactivateTool(); } else if (nextProps.isBrushModeActive && this.props.isBrushModeActive) { + const {fillColor, strokeColor, strokeWidth} = nextProps.colorState; this.blob.setOptions({ isEraser: false, - ...nextProps.colorState, + fillColor: fillColor.primary, + strokeColor: strokeColor.primary, + strokeWidth, ...nextProps.brushModeState }); } @@ -56,7 +59,7 @@ class BrushMode extends React.Component { clearSelection(this.props.clearSelectedItems); this.props.clearGradient(); // Force the default brush color if fill is MIXED or transparent - const {fillColor} = this.props.colorState; + const fillColor = this.props.colorState.fillColor.primary; if (fillColor === MIXED || fillColor === null) { this.props.onChangeFillColor(DEFAULT_COLOR); } @@ -86,8 +89,8 @@ BrushMode.propTypes = { clearGradient: PropTypes.func.isRequired, clearSelectedItems: PropTypes.func.isRequired, colorState: PropTypes.shape({ - fillColor: PropTypes.string, - strokeColor: PropTypes.string, + fillColor: ColorStyleProptype, + strokeColor: ColorStyleProptype, strokeWidth: PropTypes.number }).isRequired, handleMouseDown: PropTypes.func.isRequired, @@ -106,7 +109,7 @@ const mapDispatchToProps = dispatch => ({ dispatch(clearSelectedItems()); }, clearGradient: () => { - dispatch(clearGradient()); + dispatch(clearFillGradient()); }, handleMouseDown: () => { dispatch(changeMode(Modes.BRUSH)); diff --git a/src/containers/color-indicator.jsx b/src/containers/color-indicator.jsx new file mode 100644 index 0000000000..a6cabe50e7 --- /dev/null +++ b/src/containers/color-indicator.jsx @@ -0,0 +1,173 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import bindAll from 'lodash.bindall'; +import parseColor from 'parse-color'; +import {injectIntl, intlShape} from 'react-intl'; + +import {getSelectedLeafItems} from '../helper/selection'; +import Formats from '../lib/format'; +import {isBitmap} from '../lib/format'; +import GradientTypes from '../lib/gradient-types'; + +import ColorIndicatorComponent from '../components/color-indicator.jsx'; +import {applyColorToSelection, + applyGradientTypeToSelection, + applyStrokeWidthToSelection, + getRotatedColor, + swapColorsInSelection, + MIXED} from '../helper/style-path'; + +const makeColorIndicator = (label, isStroke) => { + class ColorIndicator extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleChangeColor', + 'handleChangeGradientType', + 'handleCloseColor', + 'handleSwap' + ]); + + // Flag to track whether an svg-update-worthy change has been made + this._hasChanged = false; + } + componentWillReceiveProps (newProps) { + const {colorModalVisible, onUpdateImage} = this.props; + if (colorModalVisible && !newProps.colorModalVisible) { + // Submit the new SVG, which also stores a single undo/redo action. + if (this._hasChanged) onUpdateImage(); + this._hasChanged = false; + } + } + handleChangeColor (newColor) { + // Stroke-selector-specific logic: if we change the stroke color from "none" to something visible, ensure + // there's a nonzero stroke width. If we change the stroke color to "none", set the stroke width to zero. + if (isStroke) { + + // Whether the old color style in this color indicator was null (completely transparent). + // If it's a solid color, this means that the first color is null. + // If it's a gradient, this means both colors are null. + const oldStyleWasNull = this.props.gradientType === GradientTypes.SOLID ? + this.props.color === null : + this.props.color === null && this.props.color2 === null; + + const otherColor = this.props.colorIndex === 1 ? this.props.color : this.props.color2; + // Whether the new color style in this color indicator is null. + const newStyleIsNull = this.props.gradientType === GradientTypes.SOLID ? + newColor === null : + newColor === null && otherColor === null; + + if (oldStyleWasNull && !newStyleIsNull) { + this._hasChanged = applyStrokeWidthToSelection(1, this.props.textEditTarget) || this._hasChanged; + this.props.onChangeStrokeWidth(1); + } else if (!oldStyleWasNull && newStyleIsNull) { + this._hasChanged = applyStrokeWidthToSelection(0, this.props.textEditTarget) || this._hasChanged; + this.props.onChangeStrokeWidth(0); + } + } + + 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, + // 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, + 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.format); + } + } + if (this.props.gradientType === GradientTypes.SOLID && gradientType !== GradientTypes.SOLID) { + // Generate color 2 and change to the 2nd swatch when switching from solid to gradient + if (!hasSelectedItems) { + this.props.onChangeColor(getRotatedColor(this.props.color), 1); + } + this.props.onChangeColorIndex(1); + } + if (this.props.onChangeGradientType) this.props.onChangeGradientType(gradientType); + } + handleCloseColor () { + // If the eyedropper is currently being used, don't + // close the color menu. + if (this.props.isEyeDropping) return; + + // Otherwise, close the color menu and + // also reset the color index to indicate + // that `color1` is selected. + this.props.onCloseColor(); + this.props.onChangeColorIndex(0); + } + handleSwap () { + if (getSelectedLeafItems().length) { + const formatIsBitmap = isBitmap(this.props.format); + const isDifferent = swapColorsInSelection( + isStroke || (formatIsBitmap && !this.props.fillBitmapShapes), + this.props.textEditTarget); + this.props.setSelectedItems(this.props.format); + this._hasChanged = this._hasChanged || isDifferent; + } else { + let color1 = this.props.color; + let color2 = this.props.color2; + color1 = color1 === null || color1 === MIXED ? color1 : parseColor(color1).hex; + color2 = color2 === null || color2 === MIXED ? color2 : parseColor(color2).hex; + this.props.onChangeColor(color1, 1); + this.props.onChangeColor(color2, 0); + } + } + render () { + return ( + + ); + } + } + + ColorIndicator.propTypes = { + colorIndex: PropTypes.number.isRequired, + disabled: PropTypes.bool.isRequired, + 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, + isEyeDropping: PropTypes.bool.isRequired, + onChangeColorIndex: PropTypes.func.isRequired, + onChangeColor: PropTypes.func.isRequired, + onChangeGradientType: PropTypes.func, + onChangeStrokeWidth: PropTypes.func, + onCloseColor: PropTypes.func.isRequired, + onUpdateImage: PropTypes.func.isRequired, + setSelectedItems: PropTypes.func.isRequired, + textEditTarget: PropTypes.number + }; + + return injectIntl(ColorIndicator); +}; + +export default makeColorIndicator; diff --git a/src/containers/fill-color-indicator.jsx b/src/containers/fill-color-indicator.jsx index 757e84737f..fc86f31f72 100644 --- a/src/containers/fill-color-indicator.jsx +++ b/src/containers/fill-color-indicator.jsx @@ -1,138 +1,39 @@ import {connect} from 'react-redux'; -import PropTypes from 'prop-types'; -import React from 'react'; -import bindAll from 'lodash.bindall'; -import parseColor from 'parse-color'; +import {defineMessages} from 'react-intl'; import {changeColorIndex} from '../reducers/color-index'; -import {changeFillColor} from '../reducers/fill-color'; -import {changeFillColor2} from '../reducers/fill-color-2'; +import {changeFillColor, changeFillColor2} from '../reducers/fill-style'; import {changeGradientType} from '../reducers/fill-mode-gradient-type'; import {openFillColor, closeFillColor} from '../reducers/modals'; import {getSelectedLeafItems} from '../helper/selection'; import {setSelectedItems} from '../reducers/selected-items'; -import Modes from '../lib/modes'; -import Formats from '../lib/format'; +import Modes, {GradientToolsModes} from '../lib/modes'; import {isBitmap} from '../lib/format'; -import GradientTypes from '../lib/gradient-types'; -import FillColorIndicatorComponent from '../components/fill-color-indicator.jsx'; -import {applyFillColorToSelection, - applyGradientTypeToSelection, - getRotatedColor, - swapColorsInSelection, - MIXED} from '../helper/style-path'; +import makeColorIndicator from './color-indicator.jsx'; -class FillColorIndicator extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'handleChangeFillColor', - 'handleChangeGradientType', - 'handleCloseFillColor', - 'handleSwap' - ]); - - // Flag to track whether an svg-update-worthy change has been made - this._hasChanged = false; - } - componentWillReceiveProps (newProps) { - const {fillColorModalVisible, onUpdateImage} = this.props; - if (fillColorModalVisible && !newProps.fillColorModalVisible) { - // Submit the new SVG, which also stores a single undo/redo action. - if (this._hasChanged) onUpdateImage(); - this._hasChanged = false; - } - } - handleChangeFillColor (newColor) { - // Apply color and update redux, but do not update svg until picker closes. - const isDifferent = applyFillColorToSelection( - newColor, - this.props.colorIndex, - this.props.gradientType === GradientTypes.SOLID, - isBitmap(this.props.format), - this.props.textEditTarget); - this._hasChanged = this._hasChanged || isDifferent; - this.props.onChangeFillColor(newColor, this.props.colorIndex); - } - handleChangeGradientType (gradientType) { - // Apply color and update redux, but do not update svg until picker closes. - const isDifferent = applyGradientTypeToSelection( - gradientType, - isBitmap(this.props.format), - this.props.textEditTarget); - this._hasChanged = this._hasChanged || isDifferent; - const hasSelectedItems = getSelectedLeafItems().length > 0; - if (hasSelectedItems) { - if (isDifferent) { - // Recalculates the swatch colors - this.props.setSelectedItems(); - } - } - if (this.props.gradientType === GradientTypes.SOLID && gradientType !== GradientTypes.SOLID) { - // Generate color 2 and change to the 2nd swatch when switching from solid to gradient - if (!hasSelectedItems) { - this.props.onChangeFillColor(getRotatedColor(this.props.fillColor), 1); - } - this.props.onChangeColorIndex(1); - } - this.props.onChangeGradientType(gradientType); +const messages = defineMessages({ + label: { + id: 'paint.paintEditor.fill', + description: 'Label for the color picker for the fill color', + defaultMessage: 'Fill' } - handleCloseFillColor () { - // If the eyedropper is currently being used, don't - // close the fill color menu. - if (this.props.isEyeDropping) return; +}); - // Otherwise, close the fill color menu and - // also reset the color index to indicate - // that `color1` is selected. - this.props.onCloseFillColor(); - this.props.onChangeColorIndex(0); - } - handleSwap () { - if (getSelectedLeafItems().length) { - const isDifferent = swapColorsInSelection( - isBitmap(this.props.format), - this.props.textEditTarget); - this.props.setSelectedItems(); - this._hasChanged = this._hasChanged || isDifferent; - } else { - let color1 = this.props.fillColor; - let color2 = this.props.fillColor2; - color1 = color1 === null || color1 === MIXED ? color1 : parseColor(color1).hex; - color2 = color2 === null || color2 === MIXED ? color2 : parseColor(color2).hex; - this.props.onChangeFillColor(color1, 1); - this.props.onChangeFillColor(color2, 0); - } - } - render () { - return ( - - ); - } -} +const FillColorIndicator = makeColorIndicator(messages.label, false); const mapStateToProps = state => ({ colorIndex: state.scratchPaint.fillMode.colorIndex, disabled: state.scratchPaint.mode === Modes.LINE, - fillColor: state.scratchPaint.color.fillColor, - fillColor2: state.scratchPaint.color.fillColor2, - fillColorModalVisible: state.scratchPaint.modals.fillColor, + 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.gradientType, + gradientType: state.scratchPaint.color.fillColor.gradientType, isEyeDropping: state.scratchPaint.color.eyeDropper.active, mode: state.scratchPaint.mode, - shouldShowGradientTools: state.scratchPaint.mode === Modes.SELECT || - state.scratchPaint.mode === Modes.RESHAPE || - state.scratchPaint.mode === Modes.FILL || - state.scratchPaint.mode === Modes.BIT_SELECT || - state.scratchPaint.mode === Modes.BIT_FILL, + shouldShowGradientTools: state.scratchPaint.mode in GradientToolsModes, textEditTarget: state.scratchPaint.textEditTarget }); @@ -140,17 +41,17 @@ const mapDispatchToProps = dispatch => ({ onChangeColorIndex: index => { dispatch(changeColorIndex(index)); }, - onChangeFillColor: (fillColor, index) => { + onChangeColor: (fillColor, index) => { if (index === 0) { dispatch(changeFillColor(fillColor)); } else if (index === 1) { dispatch(changeFillColor2(fillColor)); } }, - onOpenFillColor: () => { + onOpenColor: () => { dispatch(openFillColor()); }, - onCloseFillColor: () => { + onCloseColor: () => { dispatch(closeFillColor()); }, onChangeGradientType: gradientType => { @@ -161,24 +62,6 @@ const mapDispatchToProps = dispatch => ({ } }); -FillColorIndicator.propTypes = { - colorIndex: PropTypes.number.isRequired, - disabled: PropTypes.bool.isRequired, - fillColor: PropTypes.string, - fillColor2: PropTypes.string, - fillColorModalVisible: PropTypes.bool.isRequired, - format: PropTypes.oneOf(Object.keys(Formats)), - gradientType: PropTypes.oneOf(Object.keys(GradientTypes)).isRequired, - isEyeDropping: PropTypes.bool.isRequired, - onChangeColorIndex: PropTypes.func.isRequired, - onChangeFillColor: PropTypes.func.isRequired, - onChangeGradientType: PropTypes.func.isRequired, - onCloseFillColor: PropTypes.func.isRequired, - onUpdateImage: PropTypes.func.isRequired, - setSelectedItems: PropTypes.func.isRequired, - textEditTarget: PropTypes.number -}; - export default connect( mapStateToProps, mapDispatchToProps diff --git a/src/containers/fill-mode.jsx b/src/containers/fill-mode.jsx index 1abc9c3709..63d10815b2 100644 --- a/src/containers/fill-mode.jsx +++ b/src/containers/fill-mode.jsx @@ -7,8 +7,7 @@ import GradientTypes from '../lib/gradient-types'; import FillTool from '../helper/tools/fill-tool'; import {getRotatedColor, MIXED} from '../helper/style-path'; -import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-color'; -import {changeFillColor2} from '../reducers/fill-color-2'; +import {changeFillColor, changeFillColor2, DEFAULT_COLOR} from '../reducers/fill-style'; import {changeMode} from '../reducers/modes'; import {clearSelectedItems} from '../reducers/selected-items'; import {clearSelection} from '../helper/selection'; @@ -70,10 +69,10 @@ class FillMode extends React.Component { this.props.onChangeFillColor(DEFAULT_COLOR, 0); } const gradientType = this.props.fillModeGradientType ? - this.props.fillModeGradientType : this.props.selectModeGradientType; + this.props.fillModeGradientType : this.props.fillStyleGradientType; let fillColor2 = this.props.fillColor2; - if (gradientType !== this.props.selectModeGradientType) { - if (this.props.selectModeGradientType === GradientTypes.SOLID) { + if (gradientType !== this.props.fillStyleGradientType) { + if (this.props.fillStyleGradientType === GradientTypes.SOLID) { fillColor2 = getRotatedColor(fillColor); this.props.onChangeFillColor(fillColor2, 1); } @@ -115,23 +114,23 @@ FillMode.propTypes = { clearSelectedItems: PropTypes.func.isRequired, fillColor: PropTypes.string, fillColor2: PropTypes.string, + fillStyleGradientType: PropTypes.oneOf(Object.keys(GradientTypes)).isRequired, fillModeGradientType: PropTypes.oneOf(Object.keys(GradientTypes)), handleMouseDown: PropTypes.func.isRequired, hoveredItemId: PropTypes.number, isFillModeActive: PropTypes.bool.isRequired, onChangeFillColor: PropTypes.func.isRequired, onUpdateImage: PropTypes.func.isRequired, - selectModeGradientType: PropTypes.oneOf(Object.keys(GradientTypes)).isRequired, setHoveredItem: PropTypes.func.isRequired }; const mapStateToProps = state => ({ fillModeGradientType: state.scratchPaint.fillMode.gradientType, // Last user-selected gradient type - fillColor: state.scratchPaint.color.fillColor, - fillColor2: state.scratchPaint.color.fillColor2, + fillColor: state.scratchPaint.color.fillColor.primary, + fillColor2: state.scratchPaint.color.fillColor.secondary, + fillStyleGradientType: state.scratchPaint.color.fillColor.gradientType, // Selected item(s)' gradient type hoveredItemId: state.scratchPaint.hoveredItemId, - isFillModeActive: state.scratchPaint.mode === Modes.FILL, - selectModeGradientType: state.scratchPaint.color.gradientType + isFillModeActive: state.scratchPaint.mode === Modes.FILL }); const mapDispatchToProps = dispatch => ({ setHoveredItem: hoveredItemId => { diff --git a/src/containers/line-mode.jsx b/src/containers/line-mode.jsx index ebe011e51f..4a67111073 100644 --- a/src/containers/line-mode.jsx +++ b/src/containers/line-mode.jsx @@ -4,11 +4,12 @@ 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 {clearSelection} from '../helper/selection'; import {endPointHit, touching} from '../helper/snapping'; import {drawHitPoint, removeHitPoint} from '../helper/guides'; -import {stylePath} from '../helper/style-path'; -import {changeStrokeColor} from '../reducers/stroke-color'; +import {styleShape} from '../helper/style-path'; +import {changeStrokeColor, clearStrokeGradient} from '../reducers/stroke-style'; import {changeStrokeWidth} from '../reducers/stroke-width'; import {changeMode} from '../reducers/modes'; import {clearSelectedItems} from '../reducers/selected-items'; @@ -58,9 +59,10 @@ class LineMode extends React.Component { } activateTool () { clearSelection(this.props.clearSelectedItems); + this.props.clearGradient(); // Force the default line color if stroke is MIXED or transparent - const {strokeColor} = this.props.colorState; + const strokeColor = this.props.colorState.strokeColor.primary; if (strokeColor === MIXED || strokeColor === null) { this.props.onChangeStrokeColor(LineMode.DEFAULT_COLOR); } @@ -101,7 +103,11 @@ class LineMode extends React.Component { this.hitResult = endPointHit(event.point, LineMode.SNAP_TOLERANCE); if (this.hitResult) { this.path = this.hitResult.path; - stylePath(this.path, this.props.colorState.strokeColor, this.props.colorState.strokeWidth); + styleShape(this.path, { + fillColor: null, + strokeColor: this.props.colorState.strokeColor, + strokeWidth: this.props.colorState.strokeWidth + }); if (this.hitResult.isFirst) { this.path.reverse(); } @@ -114,7 +120,11 @@ class LineMode extends React.Component { if (!this.path) { this.path = new paper.Path(); this.path.strokeCap = 'round'; - stylePath(this.path, this.props.colorState.strokeColor, this.props.colorState.strokeWidth); + styleShape(this.path, { + fillColor: null, + strokeColor: this.props.colorState.strokeColor, + strokeWidth: this.props.colorState.strokeWidth + }); this.path.add(event.point); this.path.add(event.point); // Add second point, which is what will move when dragged @@ -186,6 +196,12 @@ class LineMode extends React.Component { } else { this.path.lastSegment.point = endPoint; } + + styleShape(this.path, { + fillColor: null, + strokeColor: this.props.colorState.strokeColor, + strokeWidth: this.props.colorState.strokeWidth + }); } onMouseUp (event) { if (event.event.button > 0 || !this.active) return; // only first mouse button @@ -225,6 +241,12 @@ class LineMode extends React.Component { this.hitResult = null; } + styleShape(this.path, { + fillColor: null, + strokeColor: this.props.colorState.strokeColor, + strokeWidth: this.props.colorState.strokeWidth + }); + if (this.path) { this.props.onUpdateImage(); this.path = null; @@ -253,10 +275,11 @@ class LineMode extends React.Component { } LineMode.propTypes = { + clearGradient: PropTypes.func.isRequired, clearSelectedItems: PropTypes.func.isRequired, colorState: PropTypes.shape({ - fillColor: PropTypes.string, - strokeColor: PropTypes.string, + fillColor: ColorStyleProptype, + strokeColor: ColorStyleProptype, strokeWidth: PropTypes.number }).isRequired, handleMouseDown: PropTypes.func.isRequired, @@ -271,6 +294,9 @@ const mapStateToProps = state => ({ isLineModeActive: state.scratchPaint.mode === Modes.LINE }); const mapDispatchToProps = dispatch => ({ + clearGradient: () => { + dispatch(clearStrokeGradient()); + }, clearSelectedItems: () => { dispatch(clearSelectedItems()); }, diff --git a/src/containers/oval-mode.jsx b/src/containers/oval-mode.jsx index b525ec9a1c..03678acd7c 100644 --- a/src/containers/oval-mode.jsx +++ b/src/containers/oval-mode.jsx @@ -4,13 +4,13 @@ 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, DEFAULT_COLOR} from '../reducers/fill-color'; -import {changeStrokeColor} from '../reducers/stroke-color'; +import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-style'; +import {changeStrokeColor} from '../reducers/stroke-style'; import {changeMode} from '../reducers/modes'; import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; -import {clearGradient} from '../reducers/selection-gradient-type'; import {setCursor} from '../reducers/cursor'; import {clearSelection, getSelectedLeafItems} from '../helper/selection'; @@ -54,11 +54,12 @@ class OvalMode extends React.Component { } activateTool () { clearSelection(this.props.clearSelectedItems); - this.props.clearGradient(); // If fill and stroke color are both mixed/transparent/absent, set fill to default and stroke to transparent. // If exactly one of fill or stroke color is set, set the other one to transparent. // This way the tool won't draw an invisible state, or be unclear about what will be drawn. - const {fillColor, strokeColor, strokeWidth} = this.props.colorState; + const {strokeWidth} = this.props.colorState; + const fillColor = this.props.colorState.fillColor.primary; + const strokeColor = this.props.colorState.strokeColor.primary; const fillColorPresent = fillColor !== MIXED && fillColor !== null; const strokeColorPresent = strokeColor !== MIXED && strokeColor !== null && strokeWidth !== null && strokeWidth !== 0; @@ -95,11 +96,10 @@ class OvalMode extends React.Component { } OvalMode.propTypes = { - clearGradient: PropTypes.func.isRequired, clearSelectedItems: PropTypes.func.isRequired, colorState: PropTypes.shape({ - fillColor: PropTypes.string, - strokeColor: PropTypes.string, + fillColor: ColorStyleProptype, + strokeColor: ColorStyleProptype, strokeWidth: PropTypes.number }).isRequired, handleMouseDown: PropTypes.func.isRequired, @@ -121,9 +121,6 @@ const mapDispatchToProps = dispatch => ({ clearSelectedItems: () => { dispatch(clearSelectedItems()); }, - clearGradient: () => { - dispatch(clearGradient()); - }, setCursor: cursorString => { dispatch(setCursor(cursorString)); }, diff --git a/src/containers/rect-mode.jsx b/src/containers/rect-mode.jsx index 9675b7f910..df8c8a3efc 100644 --- a/src/containers/rect-mode.jsx +++ b/src/containers/rect-mode.jsx @@ -4,13 +4,13 @@ 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, DEFAULT_COLOR} from '../reducers/fill-color'; -import {changeStrokeColor} from '../reducers/stroke-color'; +import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-style'; +import {changeStrokeColor} from '../reducers/stroke-style'; import {changeMode} from '../reducers/modes'; import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; -import {clearGradient} from '../reducers/selection-gradient-type'; import {setCursor} from '../reducers/cursor'; import {clearSelection, getSelectedLeafItems} from '../helper/selection'; @@ -54,11 +54,12 @@ class RectMode extends React.Component { } activateTool () { clearSelection(this.props.clearSelectedItems); - this.props.clearGradient(); // If fill and stroke color are both mixed/transparent/absent, set fill to default and stroke to transparent. // If exactly one of fill or stroke color is set, set the other one to transparent. // This way the tool won't draw an invisible state, or be unclear about what will be drawn. - const {fillColor, strokeColor, strokeWidth} = this.props.colorState; + const {strokeWidth} = this.props.colorState; + const fillColor = this.props.colorState.fillColor.primary; + const strokeColor = this.props.colorState.strokeColor.primary; const fillColorPresent = fillColor !== MIXED && fillColor !== null; const strokeColorPresent = strokeColor !== MIXED && strokeColor !== null && strokeWidth !== null && strokeWidth !== 0; @@ -95,11 +96,10 @@ class RectMode extends React.Component { } RectMode.propTypes = { - clearGradient: PropTypes.func.isRequired, clearSelectedItems: PropTypes.func.isRequired, colorState: PropTypes.shape({ - fillColor: PropTypes.string, - strokeColor: PropTypes.string, + fillColor: ColorStyleProptype, + strokeColor: ColorStyleProptype, strokeWidth: PropTypes.number }).isRequired, handleMouseDown: PropTypes.func.isRequired, @@ -121,9 +121,6 @@ const mapDispatchToProps = dispatch => ({ clearSelectedItems: () => { dispatch(clearSelectedItems()); }, - clearGradient: () => { - dispatch(clearGradient()); - }, setSelectedItems: () => { dispatch(setSelectedItems(getSelectedLeafItems(), false /* bitmapMode */)); }, diff --git a/src/containers/stroke-color-indicator.jsx b/src/containers/stroke-color-indicator.jsx index ea78e0cb83..b912bcc3dd 100644 --- a/src/containers/stroke-color-indicator.jsx +++ b/src/containers/stroke-color-indicator.jsx @@ -1,104 +1,73 @@ import {connect} from 'react-redux'; -import PropTypes from 'prop-types'; -import React from 'react'; -import bindAll from 'lodash.bindall'; -import {changeStrokeColor} from '../reducers/stroke-color'; +import {defineMessages} from 'react-intl'; + +import {changeColorIndex} from '../reducers/color-index'; +import {changeStrokeColor, changeStrokeColor2} from '../reducers/stroke-style'; import {changeStrokeWidth} from '../reducers/stroke-width'; +import {changeStrokeGradientType} from '../reducers/stroke-style'; import {openStrokeColor, closeStrokeColor} from '../reducers/modals'; -import Modes from '../lib/modes'; -import Formats from '../lib/format'; +import {getSelectedLeafItems} from '../helper/selection'; +import {setSelectedItems} from '../reducers/selected-items'; +import Modes, {GradientToolsModes} from '../lib/modes'; import {isBitmap} from '../lib/format'; -import StrokeColorIndicatorComponent from '../components/stroke-color-indicator.jsx'; -import {applyStrokeColorToSelection, applyStrokeWidthToSelection} from '../helper/style-path'; - -class StrokeColorIndicator extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'handleChangeStrokeColor', - 'handleCloseStrokeColor' - ]); +import makeColorIndicator from './color-indicator.jsx'; - // Flag to track whether an svg-update-worthy change has been made - this._hasChanged = false; - } - componentWillReceiveProps (newProps) { - const {strokeColorModalVisible, onUpdateImage} = this.props; - if (strokeColorModalVisible && !newProps.strokeColorModalVisible) { - // Submit the new SVG, which also stores a single undo/redo action. - if (this._hasChanged) onUpdateImage(); - this._hasChanged = false; - } - } - handleChangeStrokeColor (newColor) { - if (this.props.strokeColor === null && newColor !== null) { - this._hasChanged = applyStrokeWidthToSelection(1, this.props.textEditTarget) || this._hasChanged; - this.props.onChangeStrokeWidth(1); - } else if (this.props.strokeColor !== null && newColor === null) { - this._hasChanged = applyStrokeWidthToSelection(0, this.props.textEditTarget) || this._hasChanged; - this.props.onChangeStrokeWidth(0); - } - // Apply color and update redux, but do not update svg until picker closes. - this._hasChanged = - applyStrokeColorToSelection(newColor, isBitmap(this.props.format), this.props.textEditTarget) || - this._hasChanged; - this.props.onChangeStrokeColor(newColor); - } - handleCloseStrokeColor () { - if (!this.props.isEyeDropping) { - this.props.onCloseStrokeColor(); - } +const messages = defineMessages({ + label: { + id: 'paint.paintEditor.stroke', + description: 'Label for the color picker for the outline color', + defaultMessage: 'Outline' } - render () { - return ( - - ); - } -} +}); + +const StrokeColorIndicator = makeColorIndicator(messages.label, true); const mapStateToProps = state => ({ + colorIndex: state.scratchPaint.fillMode.colorIndex, disabled: state.scratchPaint.mode === Modes.BRUSH || state.scratchPaint.mode === Modes.TEXT || 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, isEyeDropping: state.scratchPaint.color.eyeDropper.active, - strokeColor: state.scratchPaint.color.strokeColor, - strokeColorModalVisible: state.scratchPaint.modals.strokeColor, + mode: state.scratchPaint.mode, + shouldShowGradientTools: state.scratchPaint.mode in GradientToolsModes, textEditTarget: state.scratchPaint.textEditTarget }); const mapDispatchToProps = dispatch => ({ - onChangeStrokeColor: strokeColor => { - dispatch(changeStrokeColor(strokeColor)); + onChangeColorIndex: index => { + dispatch(changeColorIndex(index)); + }, + onChangeColor: (strokeColor, index) => { + if (index === 0) { + dispatch(changeStrokeColor(strokeColor)); + } else if (index === 1) { + dispatch(changeStrokeColor2(strokeColor)); + } }, onChangeStrokeWidth: strokeWidth => { dispatch(changeStrokeWidth(strokeWidth)); }, - onOpenStrokeColor: () => { + onOpenColor: () => { dispatch(openStrokeColor()); }, - onCloseStrokeColor: () => { + onCloseColor: () => { dispatch(closeStrokeColor()); + }, + onChangeGradientType: gradientType => { + dispatch(changeStrokeGradientType(gradientType)); + }, + setSelectedItems: format => { + dispatch(setSelectedItems(getSelectedLeafItems(), isBitmap(format))); } }); -StrokeColorIndicator.propTypes = { - format: PropTypes.oneOf(Object.keys(Formats)), - isEyeDropping: PropTypes.bool.isRequired, - onChangeStrokeColor: PropTypes.func.isRequired, - onChangeStrokeWidth: PropTypes.func.isRequired, - onCloseStrokeColor: PropTypes.func.isRequired, - onUpdateImage: PropTypes.func.isRequired, - strokeColor: PropTypes.string, - strokeColorModalVisible: PropTypes.bool.isRequired, - textEditTarget: PropTypes.number -}; - export default connect( mapStateToProps, mapDispatchToProps diff --git a/src/containers/stroke-width-indicator.jsx b/src/containers/stroke-width-indicator.jsx index d224f44d59..ed27ff967e 100644 --- a/src/containers/stroke-width-indicator.jsx +++ b/src/containers/stroke-width-indicator.jsx @@ -3,12 +3,13 @@ import PropTypes from 'prop-types'; import React from 'react'; import bindAll from 'lodash.bindall'; import parseColor from 'parse-color'; -import {changeStrokeColor} from '../reducers/stroke-color'; +import {changeStrokeColor, changeStrokeColor2, changeStrokeGradientType} from '../reducers/stroke-style'; import {changeStrokeWidth} from '../reducers/stroke-width'; import StrokeWidthIndicatorComponent from '../components/stroke-width-indicator.jsx'; import {getSelectedLeafItems} from '../helper/selection'; -import {applyStrokeColorToSelection, applyStrokeWidthToSelection, getColorsFromSelection, MIXED} +import {applyColorToSelection, applyStrokeWidthToSelection, getColorsFromSelection, MIXED} from '../helper/style-path'; +import GradientTypes from '../lib/gradient-types'; import Modes from '../lib/modes'; import Formats from '../lib/format'; import {isBitmap} from '../lib/format'; @@ -23,15 +24,31 @@ class StrokeWidthIndicator extends React.Component { handleChangeStrokeWidth (newWidth) { let changed = applyStrokeWidthToSelection(newWidth, this.props.textEditTarget); if ((!this.props.strokeWidth || this.props.strokeWidth === 0) && newWidth > 0) { - let currentColor = getColorsFromSelection(getSelectedLeafItems(), isBitmap(this.props.format)).strokeColor; - if (currentColor === null) { - changed = applyStrokeColorToSelection('#000', isBitmap(this.props.format), this.props.textEditTarget) || + const currentColorState = getColorsFromSelection(getSelectedLeafItems(), isBitmap(this.props.format)); + + // Color counts as null if either both colors are null or the primary color is null and it's solid + // TODO: consolidate this check in one place + const wasNull = currentColorState.strokeColor === null && + (currentColorState.strokeColor2 === null || + currentColorState.strokeGradientType === GradientTypes.SOLID); + + if (wasNull) { + changed = applyColorToSelection( + '#000', + 0, // colorIndex, + true, // isSolidGradient + true, // applyToStroke + this.props.textEditTarget) || changed; - currentColor = '#000'; - } else if (currentColor !== MIXED) { - currentColor = parseColor(currentColor).hex; + // If there's no previous stroke color, default to solid black + this.props.onChangeStrokeGradientType(GradientTypes.SOLID); + this.props.onChangeStrokeColor('#000'); + } else if (currentColorState.strokeColor !== MIXED) { + // Set color state from the selected item's stroke color + this.props.onChangeStrokeGradientType(currentColorState.strokeGradientType); + this.props.onChangeStrokeColor(parseColor(currentColorState.strokeColor).hex); + this.props.onChangeStrokeColor2(parseColor(currentColorState.strokeColor2).hex); } - this.props.onChangeStrokeColor(currentColor); } this.props.onChangeStrokeWidth(newWidth); if (changed) this.props.onUpdateImage(); @@ -59,6 +76,12 @@ const mapDispatchToProps = dispatch => ({ onChangeStrokeColor: strokeColor => { dispatch(changeStrokeColor(strokeColor)); }, + onChangeStrokeColor2: strokeColor => { + dispatch(changeStrokeColor2(strokeColor)); + }, + onChangeStrokeGradientType: strokeColor => { + dispatch(changeStrokeGradientType(strokeColor)); + }, onChangeStrokeWidth: strokeWidth => { dispatch(changeStrokeWidth(strokeWidth)); } @@ -68,6 +91,8 @@ StrokeWidthIndicator.propTypes = { disabled: PropTypes.bool.isRequired, format: PropTypes.oneOf(Object.keys(Formats)), onChangeStrokeColor: PropTypes.func.isRequired, + onChangeStrokeColor2: PropTypes.func.isRequired, + onChangeStrokeGradientType: PropTypes.func.isRequired, onChangeStrokeWidth: PropTypes.func.isRequired, onUpdateImage: PropTypes.func.isRequired, strokeWidth: PropTypes.number, diff --git a/src/containers/text-mode.jsx b/src/containers/text-mode.jsx index 15a10bddf3..ce270cd5aa 100644 --- a/src/containers/text-mode.jsx +++ b/src/containers/text-mode.jsx @@ -5,15 +5,15 @@ import {connect} from 'react-redux'; import bindAll from 'lodash.bindall'; import Fonts from '../lib/fonts'; import Modes from '../lib/modes'; +import ColorStyleProptype from '../lib/color-style-proptype'; import {MIXED} from '../helper/style-path'; import {changeFont} from '../reducers/font'; -import {changeFillColor, DEFAULT_COLOR} from '../reducers/fill-color'; -import {changeStrokeColor} from '../reducers/stroke-color'; +import {changeFillColor, clearFillGradient, DEFAULT_COLOR} from '../reducers/fill-style'; +import {changeStrokeColor} from '../reducers/stroke-style'; import {changeMode} from '../reducers/modes'; import {setTextEditTarget} from '../reducers/text-edit-target'; import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; -import {clearGradient} from '../reducers/selection-gradient-type'; import {setCursor} from '../reducers/cursor'; import {clearSelection, getSelectedLeafItems} from '../helper/selection'; @@ -82,7 +82,9 @@ class TextMode extends React.Component { // If fill and stroke color are both mixed/transparent/absent, set fill to default and stroke to transparent. // If exactly one of fill or stroke color is set, set the other one to transparent. // This way the tool won't draw an invisible state, or be unclear about what will be drawn. - const {fillColor, strokeColor, strokeWidth} = nextProps.colorState; + const {strokeWidth} = nextProps.colorState; + const fillColor = nextProps.colorState.fillColor.primary; + const strokeColor = nextProps.colorState.strokeColor.primary; const fillColorPresent = fillColor !== MIXED && fillColor !== null; const strokeColorPresent = nextProps.isBitmap ? false : strokeColor !== MIXED && strokeColor !== null && strokeWidth !== null && strokeWidth !== 0; @@ -143,8 +145,8 @@ TextMode.propTypes = { clearGradient: PropTypes.func.isRequired, clearSelectedItems: PropTypes.func.isRequired, colorState: PropTypes.shape({ - fillColor: PropTypes.string, - strokeColor: PropTypes.string, + fillColor: ColorStyleProptype, + strokeColor: ColorStyleProptype, strokeWidth: PropTypes.number }).isRequired, font: PropTypes.string, @@ -184,7 +186,7 @@ const mapDispatchToProps = (dispatch, ownProps) => ({ dispatch(clearSelectedItems()); }, clearGradient: () => { - dispatch(clearGradient()); + dispatch(clearFillGradient()); }, handleChangeModeBitText: () => { dispatch(changeMode(Modes.BIT_TEXT)); diff --git a/src/helper/bit-tools/oval-tool.js b/src/helper/bit-tools/oval-tool.js index 2afdba68bd..f13f595b69 100644 --- a/src/helper/bit-tools/oval-tool.js +++ b/src/helper/bit-tools/oval-tool.js @@ -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'; @@ -76,36 +77,29 @@ class OvalTool extends paper.Tool { this.thickness = this.oval.strokeWidth; } this.filled = this.oval.strokeWidth === 0; - const color = this.filled ? this.oval.fillColor : this.oval.strokeColor; - this.color = color ? color.toCSS() : null; + // We don't need to set our color from the selected oval's color because the color state reducers will + // do that for us every time the selection changes. } else if (this.oval && this.oval.isInserted() && !this.oval.selected) { // Oval got deselected 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 - }); - } else { - this.oval = new paper.Shape.Ellipse({ - strokeColor: this.color, - strokeWidth: this.thickness, - point: event.downPoint, - strokeScaling: false, - size: 0 - }); - } + this.oval = new paper.Shape.Ellipse({ + point: event.downPoint, + size: 0, + strokeScaling: false + }); + 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(); } } diff --git a/src/helper/bit-tools/rect-tool.js b/src/helper/bit-tools/rect-tool.js index f65af52568..e31aeedbaa 100644 --- a/src/helper/bit-tools/rect-tool.js +++ b/src/helper/bit-tools/rect-tool.js @@ -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'; @@ -76,36 +77,27 @@ class RectTool extends paper.Tool { this.thickness = this.rect.strokeWidth; } this.filled = this.rect.strokeWidth === 0; - const color = this.filled ? this.rect.fillColor : this.rect.strokeColor; - this.color = color ? color.toCSS() : null; } else if (this.rect && this.rect.isInserted() && !this.rect.selected) { // Rectangle got deselected 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(); } } @@ -150,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; @@ -190,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(); } } diff --git a/src/helper/bitmap.js b/src/helper/bitmap.js index b4b7303c3d..b070e2a215 100644 --- a/src/helper/bitmap.js +++ b/src/helper/bitmap.js @@ -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 { diff --git a/src/helper/hover.js b/src/helper/hover.js index 314efe6845..60b97aeaa3 100644 --- a/src/helper/hover.js +++ b/src/helper/hover.js @@ -35,12 +35,17 @@ const getHoveredItem = function (event, hitOptions, subselect) { return null; } + let hoverGuide; if (isBoundsItem(item)) { - return hoverBounds(item); + hoverGuide = hoverBounds(item); } else if (!subselect && isGroupChild(item)) { - return hoverBounds(getRootItem(item)); + hoverGuide = hoverBounds(getRootItem(item)); + } else { + hoverGuide = hoverItem(item); } - return hoverItem(item); + hoverGuide.data.hitResult = hitResult; + + return hoverGuide; }; export { diff --git a/src/helper/style-path.js b/src/helper/style-path.js index 508c2f0c11..2f4ac02136 100644 --- a/src/helper/style-path.js +++ b/src/helper/style-path.js @@ -5,15 +5,15 @@ import {isGroup} from './group'; import {getItems} from './selection'; import GradientTypes from '../lib/gradient-types'; import parseColor from 'parse-color'; -import {DEFAULT_COLOR} from '../reducers/fill-color'; +import {DEFAULT_COLOR} from '../reducers/fill-style'; import {isCompoundPathChild} from '../helper/compound-path'; +import log from '../log/log'; const MIXED = 'scratch-paint/style-path/mixed'; // Check if the item color matches the incoming color. If the item color is a gradient, we assume // that the incoming color never matches, since we don't support gradients yet. const _colorMatch = function (itemColor, incomingColor) { - // @todo colorMatch should not be called with gradients as arguments once stroke gradients are supported if (itemColor && itemColor.type === 'gradient') return false; // Either both are null or both are the same color when converted to CSS. return (!itemColor && !incomingColor) || @@ -62,10 +62,12 @@ const getRotatedColor = function (firstColor) { * @param {?string} color2 CSS string, or null for transparent * @param {GradientType} gradientType gradient type * @param {paper.Rectangle} bounds Bounds of the object - * @param {paper.Point} radialCenter Where the center of a radial gradient should be, if the gradient is radial + * @param {?paper.Point} [radialCenter] Where the center of a radial gradient should be, if the gradient is radial. + * Defaults to center of bounds. + * @param {number} [minSize] The minimum width/height of the gradient object. * @return {paper.Color} Color object with gradient, may be null or color string if the gradient type is solid */ -const createGradientObject = function (color1, color2, gradientType, bounds, radialCenter) { +const createGradientObject = function (color1, color2, gradientType, bounds, radialCenter, minSize) { if (gradientType === GradientTypes.SOLID) return color1; if (color1 === null) { color1 = getColorStringForTransparent(color2); @@ -73,15 +75,50 @@ const createGradientObject = function (color1, color2, gradientType, bounds, rad if (color2 === null) { color2 = getColorStringForTransparent(color1); } - const halfLongestDimension = Math.max(bounds.width, bounds.height) / 2; - const start = gradientType === GradientTypes.RADIAL ? radialCenter : - gradientType === GradientTypes.VERTICAL ? bounds.topCenter : - gradientType === GradientTypes.HORIZONTAL ? bounds.leftCenter : - null; - const end = gradientType === GradientTypes.RADIAL ? start.add(new paper.Point(halfLongestDimension, 0)) : - gradientType === GradientTypes.VERTICAL ? bounds.bottomCenter : - gradientType === GradientTypes.HORIZONTAL ? bounds.rightCenter : - null; + + // Force gradients to have a minimum length. If the gradient start and end points are the same or very close + // (e.g. applying a vertical gradient to a perfectly horizontal line or vice versa), the gradient will not appear. + if (!minSize) minSize = 1e-2; + + let start; + let end; + switch (gradientType) { + case GradientTypes.HORIZONTAL: { + // clone these points so that adding/subtracting doesn't affect actual bounds + start = bounds.leftCenter.clone(); + end = bounds.rightCenter.clone(); + + const gradientSize = Math.abs(end.x - start.x); + if (gradientSize < minSize) { + const sizeDiff = (minSize - gradientSize) / 2; + end.x += sizeDiff; + start.x -= sizeDiff; + } + break; + } + case GradientTypes.VERTICAL: { + // clone these points so that adding/subtracting doesn't affect actual bounds + start = bounds.topCenter.clone(); + end = bounds.bottomCenter.clone(); + + const gradientSize = Math.abs(end.y - start.y); + if (gradientSize < minSize) { + const sizeDiff = (minSize - gradientSize) / 2; + end.y += sizeDiff; + start.y -= sizeDiff; + } + break; + } + + case GradientTypes.RADIAL: { + const halfLongestDimension = Math.max(bounds.width, bounds.height) / 2; + start = radialCenter || bounds.center; + end = start.add(new paper.Point( + Math.max(halfLongestDimension, minSize / 2), + 0)); + break; + } + } return { gradient: { stops: [color1, color2], @@ -93,17 +130,23 @@ const createGradientObject = function (color1, color2, gradientType, bounds, rad }; /** - * Called when setting fill color + * Called when setting an item's color * @param {string} colorString color, css format, or null if completely transparent * @param {number} colorIndex index of color being changed * @param {boolean} isSolidGradient True if is solid gradient. Sometimes the item has a gradient but the color * picker is set to a solid gradient. This happens when a mix of colors and gradient types is selected. * When changing the color in this case, the solid gradient should override the existing gradient on the item. - * @param {?boolean} bitmapMode True if the fill color is being set in bitmap mode + * @param {?boolean} applyToStroke True if changing the selection's stroke, false if changing its fill. * @param {?string} textEditTargetId paper.Item.id of text editing target, if any * @return {boolean} Whether the color application actually changed visibly. */ -const applyFillColorToSelection = function (colorString, colorIndex, isSolidGradient, bitmapMode, textEditTargetId) { +const applyColorToSelection = function ( + colorString, + colorIndex, + isSolidGradient, + applyToStroke, + textEditTargetId +) { const items = _getColorStateListeners(textEditTargetId); let changed = false; for (let item of items) { @@ -111,41 +154,38 @@ const applyFillColorToSelection = function (colorString, colorIndex, isSolidGrad item = item.parent; } - // In bitmap mode, fill color applies to the stroke if there is a stroke - if (bitmapMode && item.strokeColor !== null && item.strokeWidth) { - if (!_colorMatch(item.strokeColor, colorString)) { - changed = true; - item.strokeColor = colorString; - } - } else if (isSolidGradient || !item.fillColor || !item.fillColor.gradient || - !item.fillColor.gradient.stops.length === 2) { + const itemColorProp = applyToStroke ? 'strokeColor' : 'fillColor'; + const itemColor = item[itemColorProp]; + + if (isSolidGradient || !itemColor || !itemColor.gradient || + !itemColor.gradient.stops.length === 2) { // Applying a solid color - if (!_colorMatch(item.fillColor, colorString)) { + if (!_colorMatch(itemColor, colorString)) { changed = true; if (isPointTextItem(item) && !colorString) { // Allows transparent text to be hit - item.fillColor = 'rgba(0,0,0,0)'; + item[itemColorProp] = 'rgba(0,0,0,0)'; } else { - item.fillColor = colorString; + item[itemColorProp] = colorString; } } - } else if (!_colorMatch(item.fillColor.gradient.stops[colorIndex].color, colorString)) { + } else if (!_colorMatch(itemColor.gradient.stops[colorIndex].color, colorString)) { // Changing one color of an existing gradient changed = true; const otherIndex = colorIndex === 0 ? 1 : 0; if (colorString === null) { - colorString = getColorStringForTransparent(item.fillColor.gradient.stops[otherIndex].color.toCSS()); + colorString = getColorStringForTransparent(itemColor.gradient.stops[otherIndex].color.toCSS()); } const colors = [0, 0]; colors[colorIndex] = colorString; // If the other color is transparent, its RGB values need to be adjusted for the gradient to be smooth - if (item.fillColor.gradient.stops[otherIndex].color.alpha === 0) { + if (itemColor.gradient.stops[otherIndex].color.alpha === 0) { colors[otherIndex] = getColorStringForTransparent(colorString); } else { - colors[otherIndex] = item.fillColor.gradient.stops[otherIndex].color.toCSS(); + colors[otherIndex] = itemColor.gradient.stops[otherIndex].color.toCSS(); } // There seems to be a bug where setting colors on stops doesn't always update the view, so set gradient. - item.fillColor.gradient = {stops: colors, radial: item.fillColor.gradient.radial}; + itemColor.gradient = {stops: colors, radial: itemColor.gradient.radial}; } } return changed; @@ -153,11 +193,11 @@ const applyFillColorToSelection = function (colorString, colorIndex, isSolidGrad /** * Called to swap gradient colors - * @param {?boolean} bitmapMode True if the fill color is being set in bitmap mode + * @param {?boolean} applyToStroke True if changing the selection's stroke, false if changing its fill. * @param {?string} textEditTargetId paper.Item.id of text editing target, if any * @return {boolean} Whether the color application actually changed visibly. */ -const swapColorsInSelection = function (bitmapMode, textEditTargetId) { +const swapColorsInSelection = function (applyToStroke, textEditTargetId) { const items = _getColorStateListeners(textEditTargetId); let changed = false; for (const item of items) { @@ -166,21 +206,19 @@ const swapColorsInSelection = function (bitmapMode, textEditTargetId) { // that would leave us right where we started. if (isCompoundPathChild(item)) continue; - if (bitmapMode) { - // @todo - return; - } else if (!item.fillColor || !item.fillColor.gradient || !item.fillColor.gradient.stops.length === 2) { + const itemColor = applyToStroke ? item.strokeColor : item.fillColor; + if (!itemColor || !itemColor.gradient || !itemColor.gradient.stops.length === 2) { // Only one color; nothing to swap continue; - } else if (!item.fillColor.gradient.stops[0].color.equals(item.fillColor.gradient.stops[1].color)) { + } else if (!itemColor.gradient.stops[0].color.equals(itemColor.gradient.stops[1].color)) { // Changing one color of an existing gradient changed = true; const colors = [ - item.fillColor.gradient.stops[1].color.toCSS(), - item.fillColor.gradient.stops[0].color.toCSS() + itemColor.gradient.stops[1].color.toCSS(), + itemColor.gradient.stops[0].color.toCSS() ]; // There seems to be a bug where setting colors on stops doesn't always update the view, so set gradient. - item.fillColor.gradient = {stops: colors, radial: item.fillColor.gradient.radial}; + itemColor.gradient = {stops: colors, radial: itemColor.gradient.radial}; } } return changed; @@ -189,11 +227,11 @@ const swapColorsInSelection = function (bitmapMode, textEditTargetId) { /** * Called when setting gradient type * @param {GradientType} gradientType gradient type - * @param {?boolean} bitmapMode True if the fill color is being set in bitmap mode + * @param {?boolean} applyToStroke True if changing the selection's stroke, false if changing its fill. * @param {?string} textEditTargetId paper.Item.id of text editing target, if any * @return {boolean} Whether the color application actually changed visibly. */ -const applyGradientTypeToSelection = function (gradientType, bitmapMode, textEditTargetId) { +const applyGradientTypeToSelection = function (gradientType, applyToStroke, textEditTargetId) { const items = _getColorStateListeners(textEditTargetId); let changed = false; for (let item of items) { @@ -201,40 +239,42 @@ const applyGradientTypeToSelection = function (gradientType, bitmapMode, textEdi item = item.parent; } + const itemColorProp = applyToStroke ? 'strokeColor' : 'fillColor'; + const itemColor = item[itemColorProp]; + + const hasGradient = itemColor && itemColor.gradient; + let itemColor1; - if (item.fillColor === null || item.fillColor.alpha === 0) { + if (itemColor === null || itemColor.alpha === 0) { // Transparent itemColor1 = null; - } else if (!item.fillColor.gradient) { + } else if (!hasGradient) { // Solid color - itemColor1 = item.fillColor.toCSS(); - } else if (!item.fillColor.gradient.stops[0] || item.fillColor.gradient.stops[0].color.alpha === 0) { + itemColor1 = itemColor.toCSS(); + } else if (!itemColor.gradient.stops[0] || itemColor.gradient.stops[0].color.alpha === 0) { // Gradient where first color is transparent itemColor1 = null; } else { // Gradient where first color is not transparent - itemColor1 = item.fillColor.gradient.stops[0].color.toCSS(); + itemColor1 = itemColor.gradient.stops[0].color.toCSS(); } let itemColor2; - if (!item.fillColor || !item.fillColor.gradient || !item.fillColor.gradient.stops[1]) { + if (!hasGradient || !itemColor.gradient.stops[1]) { // If item color is solid or a gradient that has no 2nd color, set the 2nd color based on the first color itemColor2 = getRotatedColor(itemColor1); - } else if (item.fillColor.gradient.stops[1].color.alpha === 0) { + } else if (itemColor.gradient.stops[1].color.alpha === 0) { // Gradient has 2nd color which is transparent itemColor2 = null; } else { // Gradient has 2nd color which is not transparent - itemColor2 = item.fillColor.gradient.stops[1].color.toCSS(); + 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 (item.fillColor && item.fillColor.gradient) { + if (gradientType === GradientTypes.SOLID) { + if (itemColor && itemColor.gradient) { changed = true; - item.fillColor = itemColor1; + item[itemColorProp] = itemColor1; } continue; } @@ -245,72 +285,39 @@ const applyGradientTypeToSelection = function (gradientType, bitmapMode, textEdi if (itemColor2 === null) { itemColor2 = getColorStringForTransparent(itemColor1); } - if (gradientType === GradientTypes.RADIAL) { - const hasRadialGradient = item.fillColor && item.fillColor.gradient && item.fillColor.gradient.radial; - if (!hasRadialGradient) { - changed = true; - const halfLongestDimension = Math.max(item.bounds.width, item.bounds.height) / 2; - item.fillColor = { - gradient: { - stops: [itemColor1, itemColor2], - radial: true - }, - origin: item.position, - destination: item.position.add(new paper.Point(halfLongestDimension, 0)) - }; - } - } else if (gradientType === GradientTypes.HORIZONTAL) { - const hasHorizontalGradient = item.fillColor && item.fillColor.gradient && - !item.fillColor.gradient.radial && - Math.abs(item.fillColor.origin.y - item.fillColor.destination.y) < 1e-8; - if (!hasHorizontalGradient) { - changed = true; - item.fillColor = { - gradient: { - stops: [itemColor1, itemColor2] - }, - origin: item.bounds.leftCenter, - destination: item.bounds.rightCenter - }; - } - } else if (gradientType === GradientTypes.VERTICAL) { - const hasVerticalGradient = item.fillColor && item.fillColor.gradient && !item.fillColor.gradient.radial && - Math.abs(item.fillColor.origin.x - item.fillColor.destination.x) < 1e-8; - if (!hasVerticalGradient) { - changed = true; - item.fillColor = { - gradient: { - stops: [itemColor1, itemColor2] - }, - origin: item.bounds.topCenter, - destination: item.bounds.bottomCenter - }; - } - } - } - return changed; -}; -/** - * Called when setting stroke color - * @param {string} colorString New color, css format - * @param {?boolean} bitmapMode True if the stroke color is being set in bitmap mode - * @param {?string} textEditTargetId paper.Item.id of text editing target, if any - * @return {boolean} Whether the color application actually changed visibly. - */ -const applyStrokeColorToSelection = function (colorString, bitmapMode, textEditTargetId) { - // Bitmap mode doesn't have stroke color - if (bitmapMode) return false; - - const items = _getColorStateListeners(textEditTargetId); - let changed = false; - for (let item of items) { - if (item.parent instanceof paper.CompoundPath) { - item = item.parent; + let gradientTypeDiffers = false; + // If the item's gradient type differs from the gradient type we want to apply, then we change it + switch (gradientType) { + case GradientTypes.RADIAL: { + const hasRadialGradient = hasGradient && itemColor.gradient.radial; + gradientTypeDiffers = !hasRadialGradient; + break; + } + case GradientTypes.HORIZONTAL: { + const hasHorizontalGradient = hasGradient && !itemColor.gradient.radial && + Math.abs(itemColor.origin.y - itemColor.destination.y) < 1e-8; + gradientTypeDiffers = !hasHorizontalGradient; + break; } - if (!_colorMatch(item.strokeColor, colorString)) { + case GradientTypes.VERTICAL: { + const hasVerticalGradient = hasGradient && !itemColor.gradient.radial && + Math.abs(itemColor.origin.x - itemColor.destination.x) < 1e-8; + gradientTypeDiffers = !hasVerticalGradient; + break; + } + } + + if (gradientTypeDiffers) { changed = true; - item.strokeColor = colorString; + item[itemColorProp] = createGradientObject( + itemColor1, + itemColor2, + gradientType, + item.bounds, + null, // radialCenter + item.strokeWidth + ); } } return changed; @@ -339,6 +346,33 @@ const applyStrokeWidthToSelection = function (value, textEditTargetId) { return changed; }; +const _colorStateFromGradient = gradient => { + const colorState = {}; + // Scratch only recognizes 2 color gradients + if (gradient.stops.length === 2) { + if (gradient.radial) { + colorState.gradientType = GradientTypes.RADIAL; + } else { + // Always use horizontal for linear gradients, since horizontal and vertical gradients + // are the same with rotation. We don't want to show MIXED just because anything is rotated. + colorState.gradientType = GradientTypes.HORIZONTAL; + } + colorState.primary = gradient.stops[0].color.alpha === 0 ? + null : + gradient.stops[0].color.toCSS(); + colorState.secondary = gradient.stops[1].color.alpha === 0 ? + null : + gradient.stops[1].color.toCSS(); + } else { + if (gradient.stops.length < 2) log.warn(`Gradient has ${gradient.stops.length} stop(s)`); + + colorState.primary = MIXED; + colorState.secondary = MIXED; + } + + return colorState; +}; + /** * Get state of colors and stroke width for selection * @param {!Array} selectedItems Selected paper items @@ -349,12 +383,15 @@ const applyStrokeWidthToSelection = function (value, textEditTargetId) { * Thickness is line thickness, used in the bitmap editor */ const getColorsFromSelection = function (selectedItems, bitmapMode) { + // TODO: DRY out this code let selectionFillColorString; let selectionFillColor2String; let selectionStrokeColorString; + let selectionStrokeColor2String; let selectionStrokeWidth; let selectionThickness; - let selectionGradientType; + let selectionFillGradientType; + let selectionStrokeGradientType; let firstChild = true; for (let item of selectedItems) { @@ -365,7 +402,9 @@ const getColorsFromSelection = function (selectedItems, bitmapMode) { let itemFillColorString; let itemFillColor2String; let itemStrokeColorString; - let itemGradientType = GradientTypes.SOLID; + let itemStrokeColor2String; + let itemFillGradientType = GradientTypes.SOLID; + let itemStrokeGradientType = GradientTypes.SOLID; if (!isGroup(item)) { if (item.fillColor) { @@ -373,25 +412,10 @@ const getColorsFromSelection = function (selectedItems, bitmapMode) { if (isPointTextItem(item) && item.fillColor.alpha === 0) { itemFillColorString = null; } else if (item.fillColor.type === 'gradient') { - // Scratch only recognizes 2 color gradients - if (item.fillColor.gradient.stops.length === 2) { - if (item.fillColor.gradient.radial) { - itemGradientType = GradientTypes.RADIAL; - } else { - // Always use horizontal for linear gradients, since horizontal and vertical gradients - // are the same with rotation. We don't want to show MIXED just because anything is rotated. - itemGradientType = GradientTypes.HORIZONTAL; - } - itemFillColorString = item.fillColor.gradient.stops[0].color.alpha === 0 ? - null : - item.fillColor.gradient.stops[0].color.toCSS(); - itemFillColor2String = item.fillColor.gradient.stops[1].color.alpha === 0 ? - null : - item.fillColor.gradient.stops[1].color.toCSS(); - } else { - itemFillColorString = MIXED; - itemFillColor2String = MIXED; - } + const {primary, secondary, gradientType} = _colorStateFromGradient(item.fillColor.gradient); + itemFillColorString = primary; + itemFillColor2String = secondary; + itemFillGradientType = gradientType; } else { itemFillColorString = item.fillColor.alpha === 0 ? null : @@ -399,15 +423,40 @@ const getColorsFromSelection = function (selectedItems, bitmapMode) { } } if (item.strokeColor) { - // Stroke color is fill color in bitmap - if (bitmapMode) { - itemFillColorString = item.strokeColor.toCSS(); - } else if (item.strokeColor.type === 'gradient') { - itemStrokeColorString = MIXED; + if (item.strokeColor.type === 'gradient') { + const {primary, secondary, gradientType} = _colorStateFromGradient(item.strokeColor.gradient); + + let strokeColorString = primary; + const strokeColor2String = secondary; + let strokeGradientType = gradientType; + + // If the item's stroke width is 0, pretend the stroke color is null + if (!item.strokeWidth) { + strokeColorString = null; + strokeGradientType = GradientTypes.SOLID; + } + + // Stroke color is fill color in bitmap + if (bitmapMode) { + itemFillColorString = strokeColorString; + itemFillColor2String = strokeColor2String; + itemFillGradientType = strokeGradientType; + } else { + itemStrokeColorString = strokeColorString; + itemStrokeColor2String = strokeColor2String; + itemStrokeGradientType = strokeGradientType; + } } else { - itemStrokeColorString = item.strokeColor.alpha === 0 || !item.strokeWidth ? + const strokeColorString = item.strokeColor.alpha === 0 || !item.strokeWidth ? null : item.strokeColor.toCSS(); + + // Stroke color is fill color in bitmap + if (bitmapMode) { + itemFillColorString = strokeColorString; + } else { + itemStrokeColorString = strokeColorString; + } } } else { itemStrokeColorString = null; @@ -418,7 +467,9 @@ const getColorsFromSelection = function (selectedItems, bitmapMode) { selectionFillColorString = itemFillColorString; selectionFillColor2String = itemFillColor2String; selectionStrokeColorString = itemStrokeColorString; - selectionGradientType = itemGradientType; + selectionStrokeColor2String = itemStrokeColor2String; + selectionFillGradientType = itemFillGradientType; + selectionStrokeGradientType = itemStrokeGradientType; selectionStrokeWidth = itemStrokeColorString ? item.strokeWidth : 0; if (item.strokeWidth && item.data && item.data.zoomLevel) { selectionThickness = item.strokeWidth / item.data.zoomLevel; @@ -430,14 +481,22 @@ const getColorsFromSelection = function (selectedItems, bitmapMode) { if (itemFillColor2String !== selectionFillColor2String) { selectionFillColor2String = MIXED; } - if (itemGradientType !== selectionGradientType) { - selectionGradientType = GradientTypes.SOLID; + if (itemFillGradientType !== selectionFillGradientType) { + selectionFillGradientType = GradientTypes.SOLID; selectionFillColorString = MIXED; selectionFillColor2String = MIXED; } + if (itemStrokeGradientType !== selectionStrokeGradientType) { + selectionStrokeGradientType = GradientTypes.SOLID; + selectionStrokeColorString = MIXED; + selectionStrokeColor2String = MIXED; + } if (itemStrokeColorString !== selectionStrokeColorString) { selectionStrokeColorString = MIXED; } + if (itemStrokeColor2String !== selectionStrokeColor2String) { + selectionStrokeColor2String = MIXED; + } const itemStrokeWidth = itemStrokeColorString ? item.strokeWidth : 0; if (selectionStrokeWidth !== itemStrokeWidth) { selectionStrokeWidth = null; @@ -445,27 +504,46 @@ const getColorsFromSelection = function (selectedItems, bitmapMode) { } } // Convert selection gradient type from horizontal to vertical if first item is exactly vertical - if (selectedItems && selectedItems.length && selectionGradientType !== GradientTypes.SOLID) { + // This is because up to this point, we assume all non-radial gradients are horizontal + // Otherwise, if there were a mix of horizontal/vertical gradient types in the selection, they would show as MIXED + // whereas we want them to show as horizontal (or vertical if the first item is vertical) + if (selectedItems && selectedItems.length) { let firstItem = selectedItems[0]; if (firstItem.parent instanceof paper.CompoundPath) firstItem = firstItem.parent; - const direction = firstItem.fillColor.destination.subtract(firstItem.fillColor.origin); - if (Math.abs(direction.angle) === 90) { - selectionGradientType = GradientTypes.VERTICAL; + + if (selectionFillGradientType !== GradientTypes.SOLID) { + // Stroke color is fill color in bitmap if fill color is missing + // TODO: this whole "treat horizontal/vertical gradients specially" logic is janky; refactor at some point + const firstItemColor = (bitmapMode && firstItem.strokeColor) ? firstItem.strokeColor : firstItem.fillColor; + const direction = firstItemColor.destination.subtract(firstItemColor.origin); + if (Math.abs(direction.angle) === 90) { + selectionFillGradientType = GradientTypes.VERTICAL; + } + } + + if (selectionStrokeGradientType !== GradientTypes.SOLID) { + const direction = firstItem.strokeColor.destination.subtract(firstItem.strokeColor.origin); + if (Math.abs(direction.angle) === 90) { + selectionStrokeGradientType = GradientTypes.VERTICAL; + } } } if (bitmapMode) { return { fillColor: selectionFillColorString ? selectionFillColorString : null, fillColor2: selectionFillColor2String ? selectionFillColor2String : null, - gradientType: selectionGradientType, + fillGradientType: selectionFillGradientType, thickness: selectionThickness }; } + return { fillColor: selectionFillColorString ? selectionFillColorString : null, fillColor2: selectionFillColor2String ? selectionFillColor2String : null, - gradientType: selectionGradientType, + fillGradientType: selectionFillGradientType, strokeColor: selectionStrokeColorString ? selectionStrokeColorString : null, + strokeColor2: selectionStrokeColor2String ? selectionStrokeColor2String : null, + strokeGradientType: selectionStrokeGradientType, strokeWidth: selectionStrokeWidth || (selectionStrokeWidth === null) ? selectionStrokeWidth : 0 }; }; @@ -481,14 +559,6 @@ const styleBlob = function (path, options) { } }; -const stylePath = function (path, strokeColor, strokeWidth) { - // Make sure a visible line is drawn - path.setStrokeColor( - (strokeColor === MIXED || strokeColor === null) ? 'black' : strokeColor); - path.setStrokeWidth( - strokeWidth === null || strokeWidth === 0 ? 1 : strokeWidth); -}; - const styleCursorPreview = function (path, options) { if (options.isEraser) { path.fillColor = 'white'; @@ -503,15 +573,30 @@ const styleCursorPreview = function (path, options) { }; const styleShape = function (path, options) { - path.fillColor = options.fillColor; - path.strokeColor = options.strokeColor; - path.strokeWidth = options.strokeWidth; + for (const colorKey of ['fillColor', 'strokeColor']) { + if (options[colorKey] === null) { + path[colorKey] = null; + } else if (options[colorKey].gradientType === GradientTypes.SOLID) { + path[colorKey] = options[colorKey].primary; + } else { + const {primary, secondary, gradientType} = options[colorKey]; + path[colorKey] = createGradientObject( + primary, + secondary, + gradientType, + path.bounds, + null, // radialCenter + options.strokeWidth // minimum gradient size is stroke width + ); + } + } + + if (options.hasOwnProperty('strokeWidth')) path.strokeWidth = options.strokeWidth; }; export { - applyFillColorToSelection, + applyColorToSelection, applyGradientTypeToSelection, - applyStrokeColorToSelection, applyStrokeWidthToSelection, createGradientObject, getColorsFromSelection, @@ -519,7 +604,6 @@ export { MIXED, styleBlob, styleShape, - stylePath, styleCursorPreview, swapColorsInSelection }; diff --git a/src/helper/tools/fill-tool.js b/src/helper/tools/fill-tool.js index c82352d0b0..a34638b785 100644 --- a/src/helper/tools/fill-tool.js +++ b/src/helper/tools/fill-tool.js @@ -31,6 +31,8 @@ class FillTool extends paper.Tool { // The path that's being hovered over. this.fillItem = null; + // The style property that we're applying the color to (either fill or stroke). + this.fillProperty = null; // If we're hovering over a hole in a compound path, we can't just recolor it. This is the // added item that's the same shape as the hole that's drawn over the hole when we fill a hole. this.addedFillItem = null; @@ -43,14 +45,17 @@ class FillTool extends paper.Tool { item.lastSegment.point.getDistance(item.firstSegment.point) < 8; }; return { - segments: true, + segments: false, stroke: true, - curves: true, + curves: false, fill: true, guide: false, match: function (hitResult) { + // Allow fills to be hit only if the item has a fill already or the path is closed/nearly closed + const hitFill = hitResult.item.hasFill() || hitResult.item.closed || isAlmostClosedPath(hitResult.item); if (hitResult.item instanceof paper.Path && - (hitResult.item.hasFill() || hitResult.item.closed || isAlmostClosedPath(hitResult.item))) { + // Disallow hits that don't qualify for the fill criteria, but only if they're fills + (hitFill || hitResult.type !== 'fill')) { return true; } if (hitResult.item instanceof paper.PointText) { @@ -58,6 +63,12 @@ class FillTool extends paper.Tool { } }, hitUnfilledPaths: true, + // If the color is transparent/none, then we need to be able to hit "invisible" outlines so that we don't + // prevent ourselves from hitting an outline when we make it transparent via the fill preview, causing it to + // flicker back and forth between transparent/its previous color as we hit it, then stop hitting it, etc. + // If the color *is* visible, then don't hit "invisible" outlines, since this would add visible outlines to + // non-outlined shapes when you hovered over where their outlines would be. + hitUnstrokedPaths: this.gradientType === GradientTypes.SOLID && this.fillColor === null, tolerance: FillTool.TOLERANCE / paper.view.zoom }; } @@ -89,8 +100,13 @@ class FillTool extends paper.Tool { this.setHoveredItem(hoveredItem ? hoveredItem.id : null); } const hitItem = hoveredItem ? hoveredItem.data.origItem : null; + const hitType = hoveredItem ? hoveredItem.data.hitResult.type : null; + + // The hit "target" changes if we switch items or switch between fill/outline on the same item + const hitTargetChanged = hitItem !== this.fillItem || hitType !== this.fillProperty; + // Still hitting the same thing - if ((!hitItem && !this.fillItem) || this.fillItem === hitItem) { + if (!hitTargetChanged) { // Only radial gradient needs to be updated if (this.gradientType === GradientTypes.RADIAL) { this._setFillItemColor(this.fillColor, this.fillColor2, this.gradientType, event.point); @@ -106,14 +122,18 @@ class FillTool extends paper.Tool { } this.fillItemOrigColor = null; this.fillItem = null; + this.fillProperty = null; } if (hitItem) { this.fillItem = hitItem; - this.fillItemOrigColor = hitItem.fillColor; - if (hitItem.parent instanceof paper.CompoundPath && hitItem.area < 0) { // hole + this.fillProperty = hitType; + const colorProp = hitType === 'fill' ? 'fillColor' : 'strokeColor'; + this.fillItemOrigColor = hitItem[colorProp]; + if (hitItem.parent instanceof paper.CompoundPath && hitItem.area < 0 && hitType === 'fill') { // hole if (!this.fillColor) { // Hole filled with transparent is no-op this.fillItem = null; + this.fillProperty = null; this.fillItemOrigColor = null; return; } @@ -127,7 +147,7 @@ class FillTool extends paper.Tool { expandBy(this.addedFillItem, .1); this.addedFillItem.insertAbove(hitItem.parent); } else if (this.fillItem.parent instanceof paper.CompoundPath) { - this.fillItemOrigColor = hitItem.parent.fillColor; + this.fillItemOrigColor = hitItem.parent[colorProp]; } this._setFillItemColor(this.fillColor, this.fillColor2, this.gradientType, event.point); } @@ -163,6 +183,7 @@ class FillTool extends paper.Tool { this.clearHoveredItem(); this.fillItem = null; + this.fillProperty = null; this.addedFillItem = null; this.fillItemOrigColor = null; this.onUpdateImage(); @@ -178,12 +199,20 @@ class FillTool extends paper.Tool { _setFillItemColor (color1, color2, gradientType, pointerLocation) { const item = this._getFillItem(); if (!item) return; + const colorProp = this.fillProperty === 'fill' ? 'fillColor' : 'strokeColor'; // Only create a gradient if specifically requested, else use color1 directly // This ensures we do not set a gradient by accident (see scratch-paint#830). if (gradientType && gradientType !== GradientTypes.SOLID) { - item.fillColor = createGradientObject(color1, color2, gradientType, item.bounds, pointerLocation); + item[colorProp] = createGradientObject( + color1, + color2, + gradientType, + item.bounds, + pointerLocation, + item.strokeWidth + ); } else { - item.fillColor = color1; + item[colorProp] = color1; } } _getFillItem () { @@ -199,6 +228,7 @@ class FillTool extends paper.Tool { this._setFillItemColor(this.fillItemOrigColor); this.fillItemOrigColor = null; this.fillItem = null; + this.fillProperty = null; } this.clearHoveredItem(); this.setHoveredItem = null; diff --git a/src/helper/tools/oval-tool.js b/src/helper/tools/oval-tool.js index 1246a705b1..f1d3208a49 100644 --- a/src/helper/tools/oval-tool.js +++ b/src/helper/tools/oval-tool.js @@ -111,6 +111,8 @@ class OvalTool extends paper.Tool { } else { this.oval.position = downPoint.subtract(this.oval.size.multiply(0.5)); } + + styleShape(this.oval, this.colorState); } handleMouseMove (event) { this.boundingBoxTool.onMouseMove(event, this.getHitOptions()); diff --git a/src/helper/tools/text-tool.js b/src/helper/tools/text-tool.js index 3c20bc725b..bba8bda7a4 100644 --- a/src/helper/tools/text-tool.js +++ b/src/helper/tools/text-tool.js @@ -241,7 +241,9 @@ class TextTool extends paper.Tool { content: '', font: this.font, fontSize: 40, - fillColor: this.colorState.fillColor, + // TODO: style using gradient + // https://github.com/LLK/scratch-paint/issues/1164 + fillColor: this.colorState.fillColor.primary, // Default leading for both the HTML text area and paper.PointText // is 120%, but for some reason they are slightly off from each other. // This value was obtained experimentally. diff --git a/src/lib/color-style-proptype.js b/src/lib/color-style-proptype.js new file mode 100644 index 0000000000..6e59603f63 --- /dev/null +++ b/src/lib/color-style-proptype.js @@ -0,0 +1,9 @@ +import {PropTypes} from 'prop-types'; + +import GradientTypes from './gradient-types'; + +export default PropTypes.shape({ + primary: PropTypes.string, + secondary: PropTypes.string, + gradientType: PropTypes.oneOf(Object.keys(GradientTypes)).isRequired +}); diff --git a/src/lib/make-color-style-reducer.js b/src/lib/make-color-style-reducer.js new file mode 100644 index 0000000000..db71697987 --- /dev/null +++ b/src/lib/make-color-style-reducer.js @@ -0,0 +1,95 @@ +import log from '../log/log'; +import {CHANGE_SELECTED_ITEMS} from '../reducers/selected-items'; +import {getColorsFromSelection, MIXED} from '../helper/style-path'; +import GradientTypes from './gradient-types'; + +// Matches hex colors +const hexRegex = /^#([0-9a-f]{3}){1,2}$/i; + +const isValidHexColor = color => { + if (!hexRegex.test(color) && color !== null && color !== MIXED) { + log.warn(`Invalid hex color code: ${color}`); + return false; + } + return true; +}; + +const makeColorStyleReducer = ({ + // Action name for changing the primary color + changePrimaryColorAction, + // Action name for changing the secondary color + changeSecondaryColorAction, + // Action name for changing the gradient type + changeGradientTypeAction, + // Action name for clearing the gradient + clearGradientAction, + // Initial color when not set + defaultColor, + // The name of the property read from getColorsFromSelection to get the primary color. + // e.g. `fillColor` or `strokeColor`. + selectionPrimaryColorKey, + // The name of the property read from getColorsFromSelection to get the secondary color. + // e.g. `fillColor2` or `strokeColor2`. + selectionSecondaryColorKey, + // The name of the property read from getColorsFromSelection to get the gradient type. + // e.g. `fillGradientType` or `strokeGradientType`. + selectionGradientTypeKey +}) => function colorReducer (state, action) { + if (typeof state === 'undefined') { + state = { + primary: defaultColor, + secondary: null, + gradientType: GradientTypes.SOLID + }; + } + switch (action.type) { + case changePrimaryColorAction: + if (!isValidHexColor(action.color)) return state; + return {...state, primary: action.color}; + case changeSecondaryColorAction: + if (!isValidHexColor(action.color)) return state; + return {...state, secondary: action.color}; + case CHANGE_SELECTED_ITEMS: { + // Don't change state if no selection + if (!action.selectedItems || !action.selectedItems.length) { + return state; + } + const colors = getColorsFromSelection(action.selectedItems, action.bitmapMode); + + // Only set the primary color + gradient type if they exist in what getColorsFromSelection gave us. + // E.g. in bitmap mode, getColorsFromSelection will not return stroke color/gradient type. This allows us to + // preserve stroke swatch state across bitmap mode-- if getColorsFromSelection set them to null, then selecting + // anything in bitmap mode would overwrite the stroke state. + const newState = {...state}; + if (selectionPrimaryColorKey in colors) { + newState.primary = colors[selectionPrimaryColorKey]; + } + if (selectionGradientTypeKey in colors) { + newState.gradientType = colors[selectionGradientTypeKey]; + } + + // Gradient type may be solid when multiple gradient types are selected. + // In this case, changing the first color should not change the second color. + if ( + selectionSecondaryColorKey in colors && + (colors[selectionGradientTypeKey] !== GradientTypes.SOLID || + colors[selectionSecondaryColorKey] === MIXED) + ) { + newState.secondary = colors[selectionSecondaryColorKey]; + } + return newState; + } + case changeGradientTypeAction: + if (action.gradientType in GradientTypes) { + return {...state, gradientType: action.gradientType}; + } + log.warn(`Gradient type does not exist: ${action.gradientType}`); + return state; + case clearGradientAction: + return {...state, secondary: null, gradientType: GradientTypes.SOLID}; + default: + return state; + } +}; + +export default makeColorStyleReducer; diff --git a/src/lib/modes.js b/src/lib/modes.js index 48e9017cb0..3a0934ff6b 100644 --- a/src/lib/modes.js +++ b/src/lib/modes.js @@ -26,8 +26,23 @@ const VectorModes = keyMirror(vectorModesObj); const BitmapModes = keyMirror(bitmapModesObj); const Modes = keyMirror({...vectorModesObj, ...bitmapModesObj}); +const GradientToolsModes = keyMirror({ + FILL: null, + SELECT: null, + RESHAPE: null, + OVAL: null, + RECT: null, + LINE: null, + + BIT_OVAL: null, + BIT_RECT: null, + BIT_SELECT: null, + BIT_FILL: null +}); + export { Modes as default, VectorModes, - BitmapModes + BitmapModes, + GradientToolsModes }; diff --git a/src/reducers/color-index.js b/src/reducers/color-index.js index c762ef8333..4520f627ac 100644 --- a/src/reducers/color-index.js +++ b/src/reducers/color-index.js @@ -1,5 +1,5 @@ import log from '../log/log'; -import {CHANGE_GRADIENT_TYPE} from './fill-mode-gradient-type'; +import {CHANGE_FILL_GRADIENT_TYPE} from './fill-style'; import GradientTypes from '../lib/gradient-types'; const CHANGE_COLOR_INDEX = 'scratch-paint/color-index/CHANGE_COLOR_INDEX'; @@ -14,7 +14,7 @@ const reducer = function (state, action) { return state; } return action.index; - case CHANGE_GRADIENT_TYPE: + case CHANGE_FILL_GRADIENT_TYPE: if (action.gradientType === GradientTypes.SOLID) return 0; /* falls through */ default: diff --git a/src/reducers/color.js b/src/reducers/color.js index 847c310bdb..4d50d49bed 100644 --- a/src/reducers/color.js +++ b/src/reducers/color.js @@ -1,16 +1,12 @@ import {combineReducers} from 'redux'; import eyeDropperReducer from './eye-dropper'; -import fillColorReducer from './fill-color'; -import fillColor2Reducer from './fill-color-2'; -import gradientTypeReducer from './selection-gradient-type'; -import strokeColorReducer from './stroke-color'; +import fillColorReducer from './fill-style'; +import strokeColorReducer from './stroke-style'; import strokeWidthReducer from './stroke-width'; export default combineReducers({ eyeDropper: eyeDropperReducer, fillColor: fillColorReducer, - fillColor2: fillColor2Reducer, - gradientType: gradientTypeReducer, strokeColor: strokeColorReducer, strokeWidth: strokeWidthReducer }); diff --git a/src/reducers/fill-color-2.js b/src/reducers/fill-color-2.js deleted file mode 100644 index 5fe16d5af3..0000000000 --- a/src/reducers/fill-color-2.js +++ /dev/null @@ -1,53 +0,0 @@ -import log from '../log/log'; -import {CHANGE_SELECTED_ITEMS} from './selected-items'; -import {CLEAR_GRADIENT} from './selection-gradient-type'; -import {MIXED, getColorsFromSelection} from '../helper/style-path'; -import GradientTypes from '../lib/gradient-types'; - -const CHANGE_FILL_COLOR_2 = 'scratch-paint/fill-color/CHANGE_FILL_COLOR_2'; -// Matches hex colors -const regExp = /^#([0-9a-f]{3}){1,2}$/i; - -const reducer = function (state, action) { - if (typeof state === 'undefined') state = null; - switch (action.type) { - case CHANGE_FILL_COLOR_2: - if (!regExp.test(action.fillColor) && action.fillColor !== null && action.fillColor !== MIXED) { - log.warn(`Invalid hex color code: ${action.fillColor}`); - return state; - } - return action.fillColor; - case CHANGE_SELECTED_ITEMS: - { - // Don't change state if no selection - if (!action.selectedItems || !action.selectedItems.length) { - return state; - } - const colors = getColorsFromSelection(action.selectedItems); - if (colors.gradientType === GradientTypes.SOLID) { - // Gradient type may be solid when multiple gradient types are selected. - // In this case, changing the first color should not change the second color. - if (colors.fillColor2 === MIXED) return MIXED; - return state; - } - return colors.fillColor2; - } - case CLEAR_GRADIENT: - return null; - default: - return state; - } -}; - -// Action creators ================================== -const changeFillColor2 = function (fillColor) { - return { - type: CHANGE_FILL_COLOR_2, - fillColor: fillColor - }; -}; - -export { - reducer as default, - changeFillColor2 -}; diff --git a/src/reducers/fill-color.js b/src/reducers/fill-color.js deleted file mode 100644 index 8b194cae42..0000000000 --- a/src/reducers/fill-color.js +++ /dev/null @@ -1,43 +0,0 @@ -import log from '../log/log'; -import {CHANGE_SELECTED_ITEMS} from './selected-items'; -import {getColorsFromSelection, MIXED} from '../helper/style-path'; - -const CHANGE_FILL_COLOR = 'scratch-paint/fill-color/CHANGE_FILL_COLOR'; -const DEFAULT_COLOR = '#9966FF'; -const initialState = DEFAULT_COLOR; -// Matches hex colors -const regExp = /^#([0-9a-f]{3}){1,2}$/i; - -const reducer = function (state, action) { - if (typeof state === 'undefined') state = initialState; - switch (action.type) { - case CHANGE_FILL_COLOR: - if (!regExp.test(action.fillColor) && action.fillColor !== null && action.fillColor !== MIXED) { - log.warn(`Invalid hex color code: ${action.fillColor}`); - return state; - } - return action.fillColor; - case CHANGE_SELECTED_ITEMS: - // Don't change state if no selection - if (!action.selectedItems || !action.selectedItems.length) { - return state; - } - return getColorsFromSelection(action.selectedItems, action.bitmapMode).fillColor; - default: - return state; - } -}; - -// Action creators ================================== -const changeFillColor = function (fillColor) { - return { - type: CHANGE_FILL_COLOR, - fillColor: fillColor - }; -}; - -export { - reducer as default, - changeFillColor, - DEFAULT_COLOR -}; diff --git a/src/reducers/fill-mode-gradient-type.js b/src/reducers/fill-mode-gradient-type.js index b19753b3b0..571740f4ab 100644 --- a/src/reducers/fill-mode-gradient-type.js +++ b/src/reducers/fill-mode-gradient-type.js @@ -2,14 +2,14 @@ // and isn't overwritten by changing the selection. import GradientTypes from '../lib/gradient-types'; import log from '../log/log'; +import {CHANGE_FILL_GRADIENT_TYPE} from './fill-style'; -const CHANGE_GRADIENT_TYPE = 'scratch-paint/fill-mode-gradient-type/CHANGE_GRADIENT_TYPE'; const initialState = null; const reducer = function (state, action) { if (typeof state === 'undefined') state = initialState; switch (action.type) { - case CHANGE_GRADIENT_TYPE: + case CHANGE_FILL_GRADIENT_TYPE: if (action.gradientType in GradientTypes) { return action.gradientType; } @@ -22,16 +22,15 @@ const reducer = function (state, action) { // Action creators ================================== // Use this for user-initiated gradient type selections only. -// See reducers/selection-gradient-type.js for other ways gradient type changes. +// See reducers/fill-style.js for other ways gradient type changes. const changeGradientType = function (gradientType) { return { - type: CHANGE_GRADIENT_TYPE, + type: CHANGE_FILL_GRADIENT_TYPE, gradientType: gradientType }; }; export { reducer as default, - CHANGE_GRADIENT_TYPE, changeGradientType }; diff --git a/src/reducers/fill-style.js b/src/reducers/fill-style.js new file mode 100644 index 0000000000..b97c4befdc --- /dev/null +++ b/src/reducers/fill-style.js @@ -0,0 +1,56 @@ +import makeColorStyleReducer from '../lib/make-color-style-reducer'; + +const CHANGE_FILL_COLOR = 'scratch-paint/fill-style/CHANGE_FILL_COLOR'; +const CHANGE_FILL_COLOR_2 = 'scratch-paint/fill-style/CHANGE_FILL_COLOR_2'; +const CHANGE_FILL_GRADIENT_TYPE = 'scratch-paint/fill-style/CHANGE_FILL_GRADIENT_TYPE'; +const CLEAR_FILL_GRADIENT = 'scratch-paint/fill-style/CLEAR_FILL_GRADIENT'; +const DEFAULT_COLOR = '#9966FF'; + +const reducer = makeColorStyleReducer({ + changePrimaryColorAction: CHANGE_FILL_COLOR, + changeSecondaryColorAction: CHANGE_FILL_COLOR_2, + changeGradientTypeAction: CHANGE_FILL_GRADIENT_TYPE, + clearGradientAction: CLEAR_FILL_GRADIENT, + defaultColor: DEFAULT_COLOR, + selectionPrimaryColorKey: 'fillColor', + selectionSecondaryColorKey: 'fillColor2', + selectionGradientTypeKey: 'fillGradientType' +}); + +// Action creators ================================== +const changeFillColor = function (fillColor) { + return { + type: CHANGE_FILL_COLOR, + color: fillColor + }; +}; + +const changeFillColor2 = function (fillColor) { + return { + type: CHANGE_FILL_COLOR_2, + color: fillColor + }; +}; + +const changeFillGradientType = function (gradientType) { + return { + type: CHANGE_FILL_GRADIENT_TYPE, + gradientType + }; +}; + +const clearFillGradient = function () { + return { + type: CLEAR_FILL_GRADIENT + }; +}; + +export { + reducer as default, + changeFillColor, + changeFillColor2, + changeFillGradientType, + clearFillGradient, + DEFAULT_COLOR, + CHANGE_FILL_GRADIENT_TYPE +}; diff --git a/src/reducers/selection-gradient-type.js b/src/reducers/selection-gradient-type.js deleted file mode 100644 index c0d979186d..0000000000 --- a/src/reducers/selection-gradient-type.js +++ /dev/null @@ -1,44 +0,0 @@ -// Gradient type shown in the select tool -import GradientTypes from '../lib/gradient-types'; -import {getColorsFromSelection} from '../helper/style-path'; -import {CHANGE_SELECTED_ITEMS} from './selected-items'; -import {CHANGE_GRADIENT_TYPE} from './fill-mode-gradient-type'; -import log from '../log/log'; - -const CLEAR_GRADIENT = 'scratch-paint/selection-gradient-type/CLEAR_GRADIENT'; -const initialState = GradientTypes.SOLID; - -const reducer = function (state, action) { - if (typeof state === 'undefined') state = initialState; - switch (action.type) { - case CHANGE_GRADIENT_TYPE: - if (action.gradientType in GradientTypes) { - return action.gradientType; - } - log.warn(`Gradient type does not exist: ${action.gradientType}`); - return state; - case CLEAR_GRADIENT: - return GradientTypes.SOLID; - case CHANGE_SELECTED_ITEMS: - // Don't change state if no selection - if (!action.selectedItems || !action.selectedItems.length) { - return state; - } - return getColorsFromSelection(action.selectedItems, action.bitmapMode).gradientType; - default: - return state; - } -}; - -// Action creators ================================== -const clearGradient = function () { - return { - type: CLEAR_GRADIENT - }; -}; - -export { - reducer as default, - CLEAR_GRADIENT, - clearGradient -}; diff --git a/src/reducers/stroke-color.js b/src/reducers/stroke-color.js deleted file mode 100644 index 50cad337ff..0000000000 --- a/src/reducers/stroke-color.js +++ /dev/null @@ -1,51 +0,0 @@ -import log from '../log/log'; -import {CHANGE_SELECTED_ITEMS} from './selected-items'; -import {CHANGE_STROKE_WIDTH} from './stroke-width'; -import {getColorsFromSelection, MIXED} from '../helper/style-path'; - -const CHANGE_STROKE_COLOR = 'scratch-paint/stroke-color/CHANGE_STROKE_COLOR'; -const initialState = '#000'; -// Matches hex colors -const regExp = /^#([0-9a-f]{3}){1,2}$/i; - -const reducer = function (state, action) { - if (typeof state === 'undefined') state = initialState; - switch (action.type) { - case CHANGE_STROKE_WIDTH: - if (Math.max(0, action.strokeWidth) === 0) { - return null; - } - return state; - case CHANGE_STROKE_COLOR: - if (!regExp.test(action.strokeColor) && action.strokeColor !== null && action.strokeColor !== MIXED) { - log.warn(`Invalid hex color code: ${action.fillColor}`); - return state; - } - return action.strokeColor; - case CHANGE_SELECTED_ITEMS: - // Don't change state if no selection - if (!action.selectedItems || !action.selectedItems.length) { - return state; - } - // Bitmap mode doesn't have stroke color - if (action.bitmapMode) { - return state; - } - return getColorsFromSelection(action.selectedItems, action.bitmapMode).strokeColor; - default: - return state; - } -}; - -// Action creators ================================== -const changeStrokeColor = function (strokeColor) { - return { - type: CHANGE_STROKE_COLOR, - strokeColor: strokeColor - }; -}; - -export { - reducer as default, - changeStrokeColor -}; diff --git a/src/reducers/stroke-style.js b/src/reducers/stroke-style.js new file mode 100644 index 0000000000..1d61135609 --- /dev/null +++ b/src/reducers/stroke-style.js @@ -0,0 +1,74 @@ +import makeColorStyleReducer from '../lib/make-color-style-reducer'; + +const CHANGE_STROKE_COLOR = 'scratch-paint/stroke-style/CHANGE_STROKE_COLOR'; +const CHANGE_STROKE_COLOR_2 = 'scratch-paint/stroke-style/CHANGE_STROKE_COLOR_2'; +const CHANGE_STROKE_GRADIENT_TYPE = 'scratch-paint/stroke-style/CHANGE_STROKE_GRADIENT_TYPE'; +const CLEAR_STROKE_GRADIENT = 'scratch-paint/stroke-style/CLEAR_STROKE_GRADIENT'; +const DEFAULT_COLOR = '#000000'; + +import {CHANGE_STROKE_WIDTH} from './stroke-width'; + +const reducer = makeColorStyleReducer({ + changePrimaryColorAction: CHANGE_STROKE_COLOR, + changeSecondaryColorAction: CHANGE_STROKE_COLOR_2, + changeGradientTypeAction: CHANGE_STROKE_GRADIENT_TYPE, + clearGradientAction: CLEAR_STROKE_GRADIENT, + defaultColor: DEFAULT_COLOR, + selectionPrimaryColorKey: 'strokeColor', + selectionSecondaryColorKey: 'strokeColor2', + selectionGradientTypeKey: 'strokeGradientType' +}); + +// This is mostly the same as the generated reducer, but with one piece of extra logic to set the color to null when the +// stroke width is set to 0. +// https://redux.js.org/recipes/structuring-reducers/reusing-reducer-logic +const strokeReducer = function (state, action) { + if (action.type === CHANGE_STROKE_WIDTH && Math.max(action.strokeWidth, 0) === 0) { + // TODO: this preserves the gradient type when you change the stroke width to 0. + // Alternatively, we could set gradientType to SOLID instead of setting secondary to null, but since + // the stroke width is automatically set to 0 as soon as a "null" color is detected (including a gradient for + // which both colors are null), that would change the gradient type back to solid if you selected null for both + // gradient colors. + return {...state, primary: null, secondary: null}; + } + + return reducer(state, action); +}; + +// Action creators ================================== +const changeStrokeColor = function (strokeColor) { + return { + type: CHANGE_STROKE_COLOR, + color: strokeColor + }; +}; + +const changeStrokeColor2 = function (strokeColor) { + return { + type: CHANGE_STROKE_COLOR_2, + color: strokeColor + }; +}; + +const changeStrokeGradientType = function (gradientType) { + return { + type: CHANGE_STROKE_GRADIENT_TYPE, + gradientType + }; +}; + +const clearStrokeGradient = function () { + return { + type: CLEAR_STROKE_GRADIENT + }; +}; + +export { + strokeReducer as default, + changeStrokeColor, + changeStrokeColor2, + changeStrokeGradientType, + clearStrokeGradient, + DEFAULT_COLOR, + CHANGE_STROKE_GRADIENT_TYPE +}; diff --git a/test/unit/color-reducer.test.js b/test/unit/color-reducer.test.js new file mode 100644 index 0000000000..81183f8941 --- /dev/null +++ b/test/unit/color-reducer.test.js @@ -0,0 +1,86 @@ +/* eslint-env jest */ +import fillColorReducer, {changeFillColor} from '../../src/reducers/fill-style'; +import strokeColorReducer, {changeStrokeColor} from '../../src/reducers/stroke-style'; +import {setSelectedItems} from '../../src/reducers/selected-items'; +import {MIXED} from '../../src/helper/style-path'; +import GradientTypes from '../../src/lib/gradient-types'; +import {mockPaperRootItem} from '../__mocks__/paperMocks'; + +for (const [colorReducer, changeColor, colorProp] of [ + [fillColorReducer, changeFillColor, 'fillColor'], + [strokeColorReducer, changeStrokeColor, 'strokeColor'] +]) { + test('initialState', () => { + let defaultState; + + expect(colorReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeDefined(); + }); + + test('changeColor', () => { + let defaultState; + + // 3 value hex code + let newColor = '#fff'; + expect(colorReducer(defaultState /* state */, changeColor(newColor) /* action */).primary) + .toEqual(newColor); + expect(colorReducer({ + primary: '#010', + secondary: null, + gradientType: GradientTypes.SOLID + } /* state */, changeColor(newColor) /* action */).primary) + .toEqual(newColor); + + // 6 value hex code + newColor = '#facade'; + expect(colorReducer(defaultState /* state */, changeColor(newColor) /* action */).primary) + .toEqual(newColor); + expect(colorReducer({ + primary: '#010', + secondary: null, + gradientType: GradientTypes.SOLID + } /* state */, changeColor(newColor) /* action */).primary) + .toEqual(newColor); + }); + + test('changeColorViaSelectedItems', () => { + let defaultState; + + const color1 = 6; + const color2 = null; // transparent + let selectedItems = [mockPaperRootItem({[colorProp]: color1, strokeWidth: 1})]; + + expect(colorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */).primary) + .toEqual(color1); + selectedItems = [mockPaperRootItem({[colorProp]: color2, strokeWidth: 1})]; + expect(colorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */).primary) + .toEqual(color2); + selectedItems = [ + mockPaperRootItem({[colorProp]: color1, strokeWidth: 1}), + mockPaperRootItem({[colorProp]: color2, strokeWidth: 1}) + ]; + expect(colorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */).primary) + .toEqual(MIXED); + }); + + test('invalidChangeColor', () => { + const origState = {primary: '#fff', secondary: null, gradientType: GradientTypes.SOLID}; + + expect(colorReducer(origState /* state */, changeColor() /* action */)) + .toBe(origState); + expect(colorReducer(origState /* state */, changeColor('#') /* action */)) + .toBe(origState); + expect(colorReducer(origState /* state */, changeColor('#1') /* action */)) + .toBe(origState); + expect(colorReducer(origState /* state */, changeColor('#12') /* action */)) + .toBe(origState); + expect(colorReducer(origState /* state */, changeColor('#1234') /* action */)) + .toBe(origState); + expect(colorReducer(origState /* state */, changeColor('#12345') /* action */)) + .toBe(origState); + expect(colorReducer(origState /* state */, changeColor('#1234567') /* action */)) + .toBe(origState); + expect(colorReducer(origState /* state */, changeColor('invalid argument') /* action */)) + .toBe(origState); + }); + +} diff --git a/test/unit/fill-color-reducer.test.js b/test/unit/fill-color-reducer.test.js deleted file mode 100644 index fb83043179..0000000000 --- a/test/unit/fill-color-reducer.test.js +++ /dev/null @@ -1,67 +0,0 @@ -/* eslint-env jest */ -import fillColorReducer from '../../src/reducers/fill-color'; -import {changeFillColor} from '../../src/reducers/fill-color'; -import {setSelectedItems} from '../../src/reducers/selected-items'; -import {MIXED} from '../../src/helper/style-path'; -import {mockPaperRootItem} from '../__mocks__/paperMocks'; - -test('initialState', () => { - let defaultState; - - expect(fillColorReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeDefined(); -}); - -test('changeFillColor', () => { - let defaultState; - - // 3 value hex code - let newFillColor = '#fff'; - expect(fillColorReducer(defaultState /* state */, changeFillColor(newFillColor) /* action */)) - .toEqual(newFillColor); - expect(fillColorReducer('#010' /* state */, changeFillColor(newFillColor) /* action */)) - .toEqual(newFillColor); - - // 6 value hex code - newFillColor = '#facade'; - expect(fillColorReducer(defaultState /* state */, changeFillColor(newFillColor) /* action */)) - .toEqual(newFillColor); - expect(fillColorReducer('#010' /* state */, changeFillColor(newFillColor) /* action */)) - .toEqual(newFillColor); -}); - -test('changefillColorViaSelectedItems', () => { - let defaultState; - - const fillColor1 = 6; - const fillColor2 = null; // transparent - let selectedItems = [mockPaperRootItem({fillColor: fillColor1})]; - expect(fillColorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) - .toEqual(fillColor1); - selectedItems = [mockPaperRootItem({fillColor: fillColor2})]; - expect(fillColorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) - .toEqual(fillColor2); - selectedItems = [mockPaperRootItem({fillColor: fillColor1}), mockPaperRootItem({fillColor: fillColor2})]; - expect(fillColorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) - .toEqual(MIXED); -}); - -test('invalidChangeFillColor', () => { - const origState = '#fff'; - - expect(fillColorReducer(origState /* state */, changeFillColor() /* action */)) - .toBe(origState); - expect(fillColorReducer(origState /* state */, changeFillColor('#') /* action */)) - .toBe(origState); - expect(fillColorReducer(origState /* state */, changeFillColor('#1') /* action */)) - .toBe(origState); - expect(fillColorReducer(origState /* state */, changeFillColor('#12') /* action */)) - .toBe(origState); - expect(fillColorReducer(origState /* state */, changeFillColor('#1234') /* action */)) - .toBe(origState); - expect(fillColorReducer(origState /* state */, changeFillColor('#12345') /* action */)) - .toBe(origState); - expect(fillColorReducer(origState /* state */, changeFillColor('#1234567') /* action */)) - .toBe(origState); - expect(fillColorReducer(origState /* state */, changeFillColor('invalid argument') /* action */)) - .toBe(origState); -}); diff --git a/test/unit/stroke-color-reducer.test.js b/test/unit/stroke-color-reducer.test.js deleted file mode 100644 index 25b5522237..0000000000 --- a/test/unit/stroke-color-reducer.test.js +++ /dev/null @@ -1,79 +0,0 @@ -/* eslint-env jest */ -import strokeColorReducer from '../../src/reducers/stroke-color'; -import {changeStrokeColor} from '../../src/reducers/stroke-color'; -import {setSelectedItems} from '../../src/reducers/selected-items'; -import {MIXED} from '../../src/helper/style-path'; -import {mockPaperRootItem} from '../__mocks__/paperMocks'; - -test('initialState', () => { - let defaultState; - - expect(strokeColorReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeDefined(); -}); - -test('changeStrokeColor', () => { - let defaultState; - - // 3 value hex code - let newStrokeColor = '#fff'; - expect(strokeColorReducer(defaultState /* state */, changeStrokeColor(newStrokeColor) /* action */)) - .toEqual(newStrokeColor); - expect(strokeColorReducer('#010' /* state */, changeStrokeColor(newStrokeColor) /* action */)) - .toEqual(newStrokeColor); - - // 6 value hex code - newStrokeColor = '#facade'; - expect(strokeColorReducer(defaultState /* state */, changeStrokeColor(newStrokeColor) /* action */)) - .toEqual(newStrokeColor); - expect(strokeColorReducer('#010' /* state */, changeStrokeColor(newStrokeColor) /* action */)) - .toEqual(newStrokeColor); -}); - -test('changeStrokeColorViaSelectedItems', () => { - let defaultState; - - const strokeColor1 = 6; - const strokeColor2 = null; // transparent - let selectedItems = [mockPaperRootItem({strokeColor: strokeColor1, strokeWidth: 1})]; - expect(strokeColorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) - .toEqual(strokeColor1); - selectedItems = [mockPaperRootItem({strokeColor: strokeColor2, strokeWidth: 1})]; - expect(strokeColorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) - .toEqual(strokeColor2); - selectedItems = [mockPaperRootItem({strokeColor: strokeColor1, strokeWidth: 1}), - mockPaperRootItem({strokeColor: strokeColor2, strokeWidth: 1})]; - expect(strokeColorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) - .toEqual(MIXED); -}); - -test('showNoStrokeColorIfNoStrokeWidth', () => { - let defaultState; - - let selectedItems = [mockPaperRootItem({strokeColor: '#fff', strokeWidth: null})]; - expect(strokeColorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) - .toEqual(null); - selectedItems = [mockPaperRootItem({strokeColor: '#fff', strokeWidth: 0})]; - expect(strokeColorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) - .toEqual(null); -}); - -test('invalidChangeStrokeColor', () => { - const origState = '#fff'; - - expect(strokeColorReducer(origState /* state */, changeStrokeColor() /* action */)) - .toBe(origState); - expect(strokeColorReducer(origState /* state */, changeStrokeColor('#') /* action */)) - .toBe(origState); - expect(strokeColorReducer(origState /* state */, changeStrokeColor('#1') /* action */)) - .toBe(origState); - expect(strokeColorReducer(origState /* state */, changeStrokeColor('#12') /* action */)) - .toBe(origState); - expect(strokeColorReducer(origState /* state */, changeStrokeColor('#1234') /* action */)) - .toBe(origState); - expect(strokeColorReducer(origState /* state */, changeStrokeColor('#12345') /* action */)) - .toBe(origState); - expect(strokeColorReducer(origState /* state */, changeStrokeColor('#1234567') /* action */)) - .toBe(origState); - expect(strokeColorReducer(origState /* state */, changeStrokeColor('invalid argument') /* action */)) - .toBe(origState); -});