diff --git a/.travis.yml b/.travis.yml index cc8a5822..92dcf7da 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: node_js -sudo: false +sudo: required dist: trusty node_js: - 6 @@ -10,6 +10,8 @@ env: global: - NODE_ENV=production install: +- sudo apt-get update && sudo apt-get install -y libcairo2-dev libpango1.0-dev libssl-dev libjpeg62-dev libgif-dev pkg-config +- npm install canvas - npm --production=false install - npm --production=false update before_deploy: diff --git a/src/components/fill-color-indicator.jsx b/src/components/fill-color-indicator.jsx index 2cad237a..5d2f65d2 100644 --- a/src/components/fill-color-indicator.jsx +++ b/src/components/fill-color-indicator.jsx @@ -6,6 +6,8 @@ import BufferedInputHOC from './forms/buffered-input-hoc.jsx'; import Label from './forms/label.jsx'; import Input from './forms/input.jsx'; +import {MIXED} from '../helper/style-path'; + import styles from './paint-editor.css'; const BufferedInput = BufferedInputHOC(Input); @@ -21,7 +23,8 @@ const FillColorIndicatorComponent = props => ( @@ -29,7 +32,7 @@ const FillColorIndicatorComponent = props => ( ); FillColorIndicatorComponent.propTypes = { - fillColor: PropTypes.string.isRequired, + fillColor: PropTypes.string, intl: intlShape, onChangeFillColor: PropTypes.func.isRequired }; diff --git a/src/components/stroke-color-indicator.jsx b/src/components/stroke-color-indicator.jsx index 71531314..dcb7db5a 100644 --- a/src/components/stroke-color-indicator.jsx +++ b/src/components/stroke-color-indicator.jsx @@ -6,6 +6,8 @@ import BufferedInputHOC from './forms/buffered-input-hoc.jsx'; import Label from './forms/label.jsx'; import Input from './forms/input.jsx'; +import {MIXED} from '../helper/style-path'; + import styles from './paint-editor.css'; const BufferedInput = BufferedInputHOC(Input); @@ -21,7 +23,9 @@ const StrokeColorIndicatorComponent = props => ( @@ -31,7 +35,7 @@ const StrokeColorIndicatorComponent = props => ( StrokeColorIndicatorComponent.propTypes = { intl: intlShape, onChangeStrokeColor: PropTypes.func.isRequired, - strokeColor: PropTypes.string.isRequired + strokeColor: PropTypes.string }; export default injectIntl(StrokeColorIndicatorComponent); diff --git a/src/components/stroke-width-indicator.jsx b/src/components/stroke-width-indicator.jsx index 6b5774ed..6bdc28bc 100644 --- a/src/components/stroke-width-indicator.jsx +++ b/src/components/stroke-width-indicator.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import BufferedInputHOC from './forms/buffered-input-hoc.jsx'; import Input from './forms/input.jsx'; + import {MAX_STROKE_WIDTH} from '../reducers/stroke-width'; import styles from './paint-editor.css'; @@ -15,7 +16,7 @@ const StrokeWidthIndicatorComponent = props => ( max={MAX_STROKE_WIDTH} min="0" type="number" - value={props.strokeWidth} + value={props.strokeWidth ? props.strokeWidth : 0} onSubmit={props.onChangeStrokeWidth} /> @@ -23,7 +24,7 @@ const StrokeWidthIndicatorComponent = props => ( StrokeWidthIndicatorComponent.propTypes = { onChangeStrokeWidth: PropTypes.func.isRequired, - strokeWidth: PropTypes.number.isRequired + strokeWidth: PropTypes.number }; export default StrokeWidthIndicatorComponent; diff --git a/src/containers/blob/blob.js b/src/containers/blob/blob.js index a10565c2..3025be8d 100644 --- a/src/containers/blob/blob.js +++ b/src/containers/blob/blob.js @@ -2,7 +2,7 @@ import paper from 'paper'; import log from '../../log/log'; import BroadBrushHelper from './broad-brush-helper'; import SegmentBrushHelper from './segment-brush-helper'; -import {styleCursorPreview} from './style-path'; +import {MIXED, styleCursorPreview} from '../../helper/style-path'; import {clearSelection} from '../../helper/selection'; /** @@ -27,11 +27,18 @@ class Blobbiness { /** * @param {function} updateCallback call when the drawing has changed to let listeners know + * @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state */ - constructor (updateCallback) { + constructor (updateCallback, clearSelectedItems) { this.broadBrushHelper = new BroadBrushHelper(); this.segmentBrushHelper = new SegmentBrushHelper(); this.updateCallback = updateCallback; + this.clearSelectedItems = clearSelectedItems; + + // The following are stored to check whether these have changed and the cursor preview needs to be redrawn. + this.strokeColor = null; + this.brushSize = null; + this.fillColor = null; } /** @@ -45,7 +52,18 @@ class Blobbiness { * @param {?number} options.strokeWidth Width of the brush outline. */ setOptions (options) { - this.options = options; + const oldFillColor = this.options ? this.options.fillColor : 'black'; + const oldStrokeColor = this.options ? this.options.strokeColor : null; + const oldStrokeWidth = this.options ? this.options.strokeWidth : null; + // If values are mixed, it means the color was set by a selection contained multiple values. + // In this case keep drawing with the previous values if any. (For stroke width, null indicates + // mixed, because stroke width is required to be a number) + this.options = { + ...options, + fillColor: options.fillColor === MIXED ? oldFillColor : options.fillColor, + strokeColor: options.strokeColor === MIXED ? oldStrokeColor : options.strokeColor, + strokeWidth: options.strokeWidth === null ? oldStrokeWidth : options.strokeWidth + }; this.resizeCursorIfNeeded(); } @@ -233,7 +251,7 @@ class Blobbiness { // Eraser didn't hit anything selected, so assume they meant to erase from all instead of from subset // and deselect the selection if (items.length === 0) { - clearSelection(); + clearSelection(this.clearSelectedItems); items = paper.project.getItems({ match: function (item) { return blob.isMergeable(lastPath, item) && blob.touches(lastPath, item); diff --git a/src/containers/blob/broad-brush-helper.js b/src/containers/blob/broad-brush-helper.js index 87af4f43..7ccef404 100644 --- a/src/containers/blob/broad-brush-helper.js +++ b/src/containers/blob/broad-brush-helper.js @@ -1,6 +1,6 @@ // Broadbrush based on http://paperjs.org/tutorials/interaction/working-with-mouse-vectors/ import paper from 'paper'; -import {stylePath} from './style-path'; +import {stylePath} from '../../helper/style-path'; /** * Broad brush functions to add as listeners on the mouse. Call them when the corresponding mouse event happens diff --git a/src/containers/blob/segment-brush-helper.js b/src/containers/blob/segment-brush-helper.js index 6ccd48ce..aa29ec5f 100644 --- a/src/containers/blob/segment-brush-helper.js +++ b/src/containers/blob/segment-brush-helper.js @@ -1,5 +1,5 @@ import paper from 'paper'; -import {stylePath} from './style-path'; +import {stylePath} from '../../helper/style-path'; /** * Segment brush functions to add as listeners on the mouse. Call them when the corresponding mouse event happens diff --git a/src/containers/blob/style-path.js b/src/containers/blob/style-path.js deleted file mode 100644 index 26a2527b..00000000 --- a/src/containers/blob/style-path.js +++ /dev/null @@ -1,22 +0,0 @@ -const stylePath = function (path, options) { - if (options.isEraser) { - path.fillColor = 'white'; - } else { - path.fillColor = options.fillColor; - } -}; - -const styleCursorPreview = function (path, options) { - if (options.isEraser) { - path.fillColor = 'white'; - path.strokeColor = 'cornflowerblue'; - path.strokeWidth = 1; - } else { - path.fillColor = options.fillColor; - } -}; - -export { - stylePath, - styleCursorPreview -}; diff --git a/src/containers/brush-mode.jsx b/src/containers/brush-mode.jsx index bbf36f11..b7027c14 100644 --- a/src/containers/brush-mode.jsx +++ b/src/containers/brush-mode.jsx @@ -6,6 +6,7 @@ import Modes from '../modes/modes'; import Blobbiness from './blob/blob'; import {changeBrushSize} from '../reducers/brush-mode'; import {changeMode} from '../reducers/modes'; +import {clearSelectedItems} from '../reducers/selected-items'; import {clearSelection} from '../helper/selection'; import BrushModeComponent from '../components/brush-mode.jsx'; @@ -17,7 +18,7 @@ class BrushMode extends React.Component { 'deactivateTool', 'onScroll' ]); - this.blob = new Blobbiness(this.props.onUpdateSvg); + this.blob = new Blobbiness(this.props.onUpdateSvg, this.props.clearSelectedItems); } componentDidMount () { if (this.props.isBrushModeActive) { @@ -43,7 +44,7 @@ class BrushMode extends React.Component { activateTool () { // TODO: Instead of clearing selection, consider a kind of "draw inside" // analogous to how selection works with eraser - clearSelection(); + clearSelection(this.props.clearSelectedItems); // TODO: This is temporary until a component that provides the brush size is hooked up this.props.canvas.addEventListener('mousewheel', this.onScroll); @@ -78,10 +79,11 @@ BrushMode.propTypes = { }), canvas: PropTypes.instanceOf(Element).isRequired, changeBrushSize: PropTypes.func.isRequired, + clearSelectedItems: PropTypes.func.isRequired, colorState: PropTypes.shape({ - fillColor: PropTypes.string.isRequired, - strokeColor: PropTypes.string.isRequired, - strokeWidth: PropTypes.number.isRequired + fillColor: PropTypes.string, + strokeColor: PropTypes.string, + strokeWidth: PropTypes.number }).isRequired, handleMouseDown: PropTypes.func.isRequired, isBrushModeActive: PropTypes.bool.isRequired, @@ -94,6 +96,9 @@ const mapStateToProps = state => ({ isBrushModeActive: state.scratchPaint.mode === Modes.BRUSH }); const mapDispatchToProps = dispatch => ({ + clearSelectedItems: () => { + dispatch(clearSelectedItems()); + }, changeBrushSize: brushSize => { dispatch(changeBrushSize(brushSize)); }, diff --git a/src/containers/eraser-mode.jsx b/src/containers/eraser-mode.jsx index 62d5b4fe..423058c2 100644 --- a/src/containers/eraser-mode.jsx +++ b/src/containers/eraser-mode.jsx @@ -5,6 +5,7 @@ import bindAll from 'lodash.bindall'; import Modes from '../modes/modes'; import Blobbiness from './blob/blob'; import {changeBrushSize} from '../reducers/eraser-mode'; +import {clearSelectedItems} from '../reducers/selected-items'; import EraserModeComponent from '../components/eraser-mode.jsx'; import {changeMode} from '../reducers/modes'; @@ -16,7 +17,7 @@ class EraserMode extends React.Component { 'deactivateTool', 'onScroll' ]); - this.blob = new Blobbiness(this.props.onUpdateSvg); + this.blob = new Blobbiness(this.props.onUpdateSvg, this.props.clearSelectedItems); } componentDidMount () { if (this.props.isEraserModeActive) { @@ -65,6 +66,7 @@ class EraserMode extends React.Component { EraserMode.propTypes = { canvas: PropTypes.instanceOf(Element).isRequired, changeBrushSize: PropTypes.func.isRequired, + clearSelectedItems: PropTypes.func.isRequired, eraserModeState: PropTypes.shape({ brushSize: PropTypes.number.isRequired }), @@ -78,6 +80,9 @@ const mapStateToProps = state => ({ isEraserModeActive: state.scratchPaint.mode === Modes.ERASER }); const mapDispatchToProps = dispatch => ({ + clearSelectedItems: () => { + dispatch(clearSelectedItems()); + }, changeBrushSize: brushSize => { dispatch(changeBrushSize(brushSize)); }, diff --git a/src/containers/fill-color-indicator.jsx b/src/containers/fill-color-indicator.jsx index 494be917..35611f45 100644 --- a/src/containers/fill-color-indicator.jsx +++ b/src/containers/fill-color-indicator.jsx @@ -1,12 +1,14 @@ import {connect} from 'react-redux'; import {changeFillColor} from '../reducers/fill-color'; import FillColorIndicatorComponent from '../components/fill-color-indicator.jsx'; +import {applyFillColorToSelection} from '../helper/style-path'; const mapStateToProps = state => ({ fillColor: state.scratchPaint.color.fillColor }); const mapDispatchToProps = dispatch => ({ onChangeFillColor: fillColor => { + applyFillColorToSelection(fillColor); dispatch(changeFillColor(fillColor)); } }); diff --git a/src/containers/line-mode.jsx b/src/containers/line-mode.jsx index 0af6f939..69356d62 100644 --- a/src/containers/line-mode.jsx +++ b/src/containers/line-mode.jsx @@ -4,7 +4,9 @@ import {connect} from 'react-redux'; import bindAll from 'lodash.bindall'; import Modes from '../modes/modes'; import {changeStrokeWidth} from '../reducers/stroke-width'; -import {clearSelection} from '../helper/selection'; +import {clearSelection, getSelectedLeafItems} from '../helper/selection'; +import {MIXED} from '../helper/style-path'; +import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; import LineModeComponent from '../components/line-mode.jsx'; import {changeMode} from '../reducers/modes'; import paper from 'paper'; @@ -43,7 +45,7 @@ class LineMode extends React.Component { return false; // Static component, for now } activateTool () { - clearSelection(); + clearSelection(this.props.clearSelectedItems); this.props.canvas.addEventListener('mousewheel', this.onScroll); this.tool = new paper.Tool(); @@ -93,9 +95,12 @@ class LineMode extends React.Component { if (!this.path) { this.path = new paper.Path(); - this.path.setStrokeColor(this.props.colorState.strokeColor); + this.path.setStrokeColor( + this.props.colorState.strokeColor === MIXED ? 'black' : this.props.colorState.strokeColor); // Make sure a visible line is drawn - this.path.setStrokeWidth(Math.max(1, this.props.colorState.strokeWidth)); + this.path.setStrokeWidth( + this.props.colorState.strokeWidth === null || this.props.colorState.strokeWidth === 0 ? + 1 : this.props.colorState.strokeWidth); this.path.setSelected(true); this.path.add(event.point); @@ -202,6 +207,7 @@ class LineMode extends React.Component { this.hitResult = null; } this.props.onUpdateSvg(); + this.props.setSelectedItems(); // TODO add back undo // if (this.path) { @@ -269,14 +275,16 @@ class LineMode extends React.Component { LineMode.propTypes = { canvas: PropTypes.instanceOf(Element).isRequired, changeStrokeWidth: PropTypes.func.isRequired, + clearSelectedItems: PropTypes.func.isRequired, colorState: PropTypes.shape({ - fillColor: PropTypes.string.isRequired, - strokeColor: PropTypes.string.isRequired, - strokeWidth: PropTypes.number.isRequired + fillColor: PropTypes.string, + strokeColor: PropTypes.string, + strokeWidth: PropTypes.number }).isRequired, handleMouseDown: PropTypes.func.isRequired, isLineModeActive: PropTypes.bool.isRequired, - onUpdateSvg: PropTypes.func.isRequired + onUpdateSvg: PropTypes.func.isRequired, + setSelectedItems: PropTypes.func.isRequired }; const mapStateToProps = state => ({ @@ -287,6 +295,12 @@ const mapDispatchToProps = dispatch => ({ changeStrokeWidth: strokeWidth => { dispatch(changeStrokeWidth(strokeWidth)); }, + clearSelectedItems: () => { + dispatch(clearSelectedItems()); + }, + setSelectedItems: () => { + dispatch(setSelectedItems(getSelectedLeafItems())); + }, handleMouseDown: () => { dispatch(changeMode(Modes.LINE)); } diff --git a/src/containers/reshape-mode.jsx b/src/containers/reshape-mode.jsx index 2fb3dc55..1683874c 100644 --- a/src/containers/reshape-mode.jsx +++ b/src/containers/reshape-mode.jsx @@ -5,7 +5,9 @@ import bindAll from 'lodash.bindall'; import Modes from '../modes/modes'; import {changeMode} from '../reducers/modes'; -import {setHoveredItem, clearHoveredItem} from '../reducers/hover'; +import {clearHoveredItem, setHoveredItem} from '../reducers/hover'; +import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; +import {getSelectedLeafItems} from '../helper/selection'; import ReshapeTool from '../helper/selection-tools/reshape-tool'; import ReshapeModeComponent from '../components/reshape-mode.jsx'; @@ -38,7 +40,12 @@ class ReshapeMode extends React.Component { return false; // Static component, for now } activateTool () { - this.tool = new ReshapeTool(this.props.setHoveredItem, this.props.clearHoveredItem, this.props.onUpdateSvg); + this.tool = new ReshapeTool( + this.props.setHoveredItem, + this.props.clearHoveredItem, + this.props.setSelectedItems, + this.props.clearSelectedItems, + this.props.onUpdateSvg); this.tool.setPrevHoveredItemId(this.props.hoveredItemId); this.tool.activate(); } @@ -57,11 +64,13 @@ class ReshapeMode extends React.Component { ReshapeMode.propTypes = { clearHoveredItem: PropTypes.func.isRequired, + clearSelectedItems: PropTypes.func.isRequired, handleMouseDown: PropTypes.func.isRequired, hoveredItemId: PropTypes.number, isReshapeModeActive: PropTypes.bool.isRequired, onUpdateSvg: PropTypes.func.isRequired, - setHoveredItem: PropTypes.func.isRequired + setHoveredItem: PropTypes.func.isRequired, + setSelectedItems: PropTypes.func.isRequired }; const mapStateToProps = state => ({ @@ -75,6 +84,12 @@ const mapDispatchToProps = dispatch => ({ clearHoveredItem: () => { dispatch(clearHoveredItem()); }, + clearSelectedItems: () => { + dispatch(clearSelectedItems()); + }, + setSelectedItems: () => { + dispatch(setSelectedItems(getSelectedLeafItems())); + }, handleMouseDown: () => { dispatch(changeMode(Modes.RESHAPE)); } diff --git a/src/containers/select-mode.jsx b/src/containers/select-mode.jsx index 3992d3f9..505bf412 100644 --- a/src/containers/select-mode.jsx +++ b/src/containers/select-mode.jsx @@ -5,8 +5,10 @@ import bindAll from 'lodash.bindall'; import Modes from '../modes/modes'; import {changeMode} from '../reducers/modes'; -import {setHoveredItem, clearHoveredItem} from '../reducers/hover'; +import {clearHoveredItem, setHoveredItem} from '../reducers/hover'; +import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; +import {getSelectedLeafItems} from '../helper/selection'; import SelectTool from '../helper/selection-tools/select-tool'; import SelectModeComponent from '../components/select-mode.jsx'; @@ -38,7 +40,12 @@ class SelectMode extends React.Component { return false; // Static component, for now } activateTool () { - this.tool = new SelectTool(this.props.setHoveredItem, this.props.clearHoveredItem, this.props.onUpdateSvg); + this.tool = new SelectTool( + this.props.setHoveredItem, + this.props.clearHoveredItem, + this.props.setSelectedItems, + this.props.clearSelectedItems, + this.props.onUpdateSvg); this.tool.activate(); } deactivateTool () { @@ -55,11 +62,13 @@ class SelectMode extends React.Component { SelectMode.propTypes = { clearHoveredItem: PropTypes.func.isRequired, + clearSelectedItems: PropTypes.func.isRequired, handleMouseDown: PropTypes.func.isRequired, hoveredItemId: PropTypes.number, isSelectModeActive: PropTypes.bool.isRequired, onUpdateSvg: PropTypes.func.isRequired, - setHoveredItem: PropTypes.func.isRequired + setHoveredItem: PropTypes.func.isRequired, + setSelectedItems: PropTypes.func.isRequired }; const mapStateToProps = state => ({ @@ -73,6 +82,12 @@ const mapDispatchToProps = dispatch => ({ clearHoveredItem: () => { dispatch(clearHoveredItem()); }, + clearSelectedItems: () => { + dispatch(clearSelectedItems()); + }, + setSelectedItems: () => { + dispatch(setSelectedItems(getSelectedLeafItems())); + }, handleMouseDown: () => { dispatch(changeMode(Modes.SELECT)); } diff --git a/src/containers/selection-hoc.jsx b/src/containers/selection-hoc.jsx index e468a179..d81a1c07 100644 --- a/src/containers/selection-hoc.jsx +++ b/src/containers/selection-hoc.jsx @@ -1,8 +1,9 @@ +import paper from 'paper'; + import PropTypes from 'prop-types'; import React from 'react'; import {connect} from 'react-redux'; import bindAll from 'lodash.bindall'; -import paper from 'paper'; const SelectionHOC = function (WrappedComponent) { class SelectionComponent extends React.Component { diff --git a/src/containers/stroke-color-indicator.jsx b/src/containers/stroke-color-indicator.jsx index add989bf..f7ffcbab 100644 --- a/src/containers/stroke-color-indicator.jsx +++ b/src/containers/stroke-color-indicator.jsx @@ -1,12 +1,14 @@ import {connect} from 'react-redux'; import {changeStrokeColor} from '../reducers/stroke-color'; import StrokeColorIndicatorComponent from '../components/stroke-color-indicator.jsx'; +import {applyStrokeColorToSelection} from '../helper/style-path'; const mapStateToProps = state => ({ strokeColor: state.scratchPaint.color.strokeColor }); const mapDispatchToProps = dispatch => ({ onChangeStrokeColor: strokeColor => { + applyStrokeColorToSelection(strokeColor); dispatch(changeStrokeColor(strokeColor)); } }); diff --git a/src/containers/stroke-width-indicator.jsx b/src/containers/stroke-width-indicator.jsx index 5e5fa967..d1b0def3 100644 --- a/src/containers/stroke-width-indicator.jsx +++ b/src/containers/stroke-width-indicator.jsx @@ -1,12 +1,14 @@ import {connect} from 'react-redux'; import {changeStrokeWidth} from '../reducers/stroke-width'; import StrokeWidthIndicatorComponent from '../components/stroke-width-indicator.jsx'; +import {applyStrokeWidthToSelection} from '../helper/style-path'; const mapStateToProps = state => ({ strokeWidth: state.scratchPaint.color.strokeWidth }); const mapDispatchToProps = dispatch => ({ onChangeStrokeWidth: strokeWidth => { + applyStrokeWidthToSelection(strokeWidth); dispatch(changeStrokeWidth(strokeWidth)); } }); diff --git a/src/helper/group.js b/src/helper/group.js index e6d5e63e..792753c0 100644 --- a/src/helper/group.js +++ b/src/helper/group.js @@ -1,16 +1,16 @@ import paper from 'paper'; import {getRootItem, isGroupItem} from './item'; -import {clearSelection, getSelectedItems, setItemSelection} from './selection'; +import {clearSelection, getSelectedRootItems, setItemSelection} from './selection'; const isGroup = function (item) { return isGroupItem(item); }; -const groupSelection = function () { - const items = getSelectedItems(); +const groupSelection = function (clearSelectedItems) { + const items = getSelectedRootItems(); if (items.length > 0) { const group = new paper.Group(items); - clearSelection(); + clearSelection(clearSelectedItems); setItemSelection(group, true); for (let i = 0; i < group.children.length; i++) { group.children[i].selected = true; @@ -47,8 +47,8 @@ const ungroupLoop = function (group, recursive) { }; // ungroup items (only top hierarchy) -const ungroupItems = function (items) { - clearSelection(); +const ungroupItems = function (items, clearSelectedItems) { + clearSelection(clearSelectedItems); const emptyGroups = []; for (let i = 0; i < items.length; i++) { const item = items[i]; @@ -71,7 +71,7 @@ const ungroupItems = function (items) { }; const ungroupSelection = function () { - const items = getSelectedItems(); + const items = getSelectedRootItems(); ungroupItems(items); }; @@ -102,12 +102,12 @@ const isGroupChild = function (item) { }; const shouldShowGroup = function () { - const items = getSelectedItems(); + const items = getSelectedRootItems(); return items.length > 1; }; const shouldShowUngroup = function () { - const items = getSelectedItems(); + const items = getSelectedRootItems(); for (let i = 0; i < items.length; i++) { const item = items[i]; if (isGroup(item) && !item.data.isPGTextItem && item.children && item.children.length > 0) { diff --git a/src/helper/guides.js b/src/helper/guides.js index c6f917b8..ae5c569e 100644 --- a/src/helper/guides.js +++ b/src/helper/guides.js @@ -1,6 +1,6 @@ import paper from 'paper'; import {getGuideLayer} from './layer'; -import {getAllPaperItems} from './selection'; +import {getAllRootItems} from './selection'; const GUIDE_BLUE = '#009dec'; const GUIDE_GREY = '#aaaaaa'; @@ -66,7 +66,7 @@ const getGuideColor = function (colorName) { }; const _removePaperItemsByDataTags = function (tags) { - const allItems = getAllPaperItems(true); + const allItems = getAllRootItems(true); for (const item of allItems) { for (const tag of tags) { if (item.data && item.data[tag]) { @@ -77,7 +77,7 @@ const _removePaperItemsByDataTags = function (tags) { }; const _removePaperItemsByTags = function (tags) { - const allItems = getAllPaperItems(true); + const allItems = getAllRootItems(true); for (const item of allItems) { for (const tag of tags) { if (item[tag]) { diff --git a/src/helper/selection-tools/bounding-box-tool.js b/src/helper/selection-tools/bounding-box-tool.js index dca535fd..1826f365 100644 --- a/src/helper/selection-tools/bounding-box-tool.js +++ b/src/helper/selection-tools/bounding-box-tool.js @@ -1,7 +1,7 @@ import paper from 'paper'; import keyMirror from 'keymirror'; -import {clearSelection, getSelectedItems} from '../selection'; +import {getSelectedRootItems} from '../selection'; import {getGuideColor, removeHelperItems} from '../guides'; import {getGuideLayer} from '../layer'; @@ -31,7 +31,12 @@ const Modes = keyMirror({ * @param {!function} onUpdateSvg A callback to call when the image visibly changes */ class BoundingBoxTool { - constructor (onUpdateSvg) { + /** + * @param {function} setSelectedItems Callback to set the set of selected items in the Redux state + * @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state + * @param {!function} onUpdateSvg A callback to call when the image visibly changes + */ + constructor (setSelectedItems, clearSelectedItems, onUpdateSvg) { this.onUpdateSvg = onUpdateSvg; this.mode = null; this.boundsPath = null; @@ -40,7 +45,7 @@ class BoundingBoxTool { this._modeMap = {}; this._modeMap[Modes.SCALE] = new ScaleTool(onUpdateSvg); this._modeMap[Modes.ROTATE] = new RotateTool(onUpdateSvg); - this._modeMap[Modes.MOVE] = new MoveTool(onUpdateSvg); + this._modeMap[Modes.MOVE] = new MoveTool(setSelectedItems, clearSelectedItems, onUpdateSvg); } /** @@ -56,7 +61,6 @@ class BoundingBoxTool { if (!hitResults || hitResults.length === 0) { if (!multiselect) { this.removeBoundsPath(); - clearSelection(); } return false; } @@ -86,9 +90,9 @@ class BoundingBoxTool { this._modeMap[this.mode].onMouseDown(hitProperties); } else if (this.mode === Modes.SCALE) { this._modeMap[this.mode].onMouseDown( - hitResult, this.boundsPath, this.boundsScaleHandles, this.boundsRotHandles, getSelectedItems()); + hitResult, this.boundsPath, this.boundsScaleHandles, this.boundsRotHandles, getSelectedRootItems()); } else if (this.mode === Modes.ROTATE) { - this._modeMap[this.mode].onMouseDown(hitResult, this.boundsPath, getSelectedItems()); + this._modeMap[this.mode].onMouseDown(hitResult, this.boundsPath, getSelectedRootItems()); } // while transforming object, never show the bounds stuff @@ -109,7 +113,7 @@ class BoundingBoxTool { setSelectionBounds () { this.removeBoundsPath(); - const items = getSelectedItems(true /* recursive */); + const items = getSelectedRootItems(); if (items.length <= 0) return; let rect = null; diff --git a/src/helper/selection-tools/handle-tool.js b/src/helper/selection-tools/handle-tool.js index 26aead16..c3d26a1d 100644 --- a/src/helper/selection-tools/handle-tool.js +++ b/src/helper/selection-tools/handle-tool.js @@ -1,12 +1,16 @@ -import {clearSelection, getSelectedItems} from '../selection'; +import {clearSelection, getSelectedLeafItems} from '../selection'; /** Sub tool of the Reshape tool for moving handles, which adjust bezier curves. */ class HandleTool { /** + * @param {function} setSelectedItems Callback to set the set of selected items in the Redux state + * @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state * @param {!function} onUpdateSvg A callback to call when the image visibly changes */ - constructor (onUpdateSvg) { + constructor (setSelectedItems, clearSelectedItems, onUpdateSvg) { this.hitType = null; + this.setSelectedItems = setSelectedItems; + this.clearSelectedItems = clearSelectedItems; this.onUpdateSvg = onUpdateSvg; } /** @@ -16,7 +20,7 @@ class HandleTool { */ onMouseDown (hitProperties) { if (!hitProperties.multiselect) { - clearSelection(); + clearSelection(this.clearSelectedItems); } hitProperties.hitResult.segment.handleIn.selected = true; @@ -24,7 +28,7 @@ class HandleTool { this.hitType = hitProperties.hitResult.type; } onMouseDrag (event) { - const selectedItems = getSelectedItems(true /* recursive */); + const selectedItems = getSelectedLeafItems(); for (const item of selectedItems) { for (const seg of item.segments) { diff --git a/src/helper/selection-tools/move-tool.js b/src/helper/selection-tools/move-tool.js index 8853e631..0a16277b 100644 --- a/src/helper/selection-tools/move-tool.js +++ b/src/helper/selection-tools/move-tool.js @@ -1,16 +1,20 @@ import {isGroup} from '../group'; import {isCompoundPathItem, getRootItem} from '../item'; import {snapDeltaToAngle} from '../math'; -import {clearSelection, cloneSelection, getSelectedItems, setItemSelection} from '../selection'; +import {clearSelection, cloneSelection, getSelectedLeafItems, setItemSelection} from '../selection'; /** * Tool to handle dragging an item to reposition it in a selection mode. */ class MoveTool { /** + * @param {function} setSelectedItems Callback to set the set of selected items in the Redux state + * @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state * @param {!function} onUpdateSvg A callback to call when the image visibly changes */ - constructor (onUpdateSvg) { + constructor (setSelectedItems, clearSelectedItems, onUpdateSvg) { + this.setSelectedItems = setSelectedItems; + this.clearSelectedItems = clearSelectedItems; this.selectedItems = null; this.onUpdateSvg = onUpdateSvg; } @@ -34,7 +38,7 @@ class MoveTool { // Double click causes all points to be selected in subselect mode. if (hitProperties.doubleClicked) { if (!hitProperties.multiselect) { - clearSelection(); + clearSelection(this.clearSelectedItems); } this._select(item, true /* state */, hitProperties.subselect, true /* fullySelect */); } else if (hitProperties.multiselect) { @@ -43,12 +47,12 @@ class MoveTool { } else { // deselect all by default if multiselect isn't on if (!hitProperties.multiselect) { - clearSelection(); + clearSelection(this.clearSelectedItems); } this._select(item, true, hitProperties.subselect); } if (hitProperties.clone) cloneSelection(hitProperties.subselect); - this.selectedItems = getSelectedItems(true /* subselect */); + this.selectedItems = getSelectedLeafItems(); } /** * Sets the selection state of an item. @@ -71,6 +75,7 @@ class MoveTool { } else { setItemSelection(item, state); } + this.setSelectedItems(); } onMouseDrag (event) { const dragVector = event.point.subtract(event.downPoint); diff --git a/src/helper/selection-tools/point-tool.js b/src/helper/selection-tools/point-tool.js index 12aa53ae..9b54cb7e 100644 --- a/src/helper/selection-tools/point-tool.js +++ b/src/helper/selection-tools/point-tool.js @@ -1,13 +1,15 @@ import paper from 'paper'; import {snapDeltaToAngle} from '../math'; -import {clearSelection, getSelectedItems} from '../selection'; +import {clearSelection, getSelectedLeafItems} from '../selection'; /** Subtool of ReshapeTool for moving control points. */ class PointTool { /** + * @param {function} setSelectedItems Callback to set the set of selected items in the Redux state + * @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state * @param {!function} onUpdateSvg A callback to call when the image visibly changes */ - constructor (onUpdateSvg) { + constructor (setSelectedItems, clearSelectedItems, onUpdateSvg) { /** * Deselection often does not happen until mouse up. If the mouse is dragged before * mouse up, deselection is cancelled. This variable keeps track of which paper.Item to deselect. @@ -24,6 +26,8 @@ class PointTool { */ this.invertDeselect = false; this.selectedItems = null; + this.setSelectedItems = setSelectedItems; + this.clearSelectedItems = clearSelectedItems; this.onUpdateSvg = onUpdateSvg; } @@ -49,12 +53,12 @@ class PointTool { } } else { if (!hitProperties.multiselect) { - clearSelection(); + clearSelection(this.clearSelectedItems); } hitProperties.hitResult.segment.selected = true; } - this.selectedItems = getSelectedItems(true /* recursive */); + this.selectedItems = getSelectedLeafItems(); } /** * @param {!object} hitProperties Describes the mouse event @@ -86,7 +90,7 @@ class PointTool { hitProperties.hitResult.item.insert(hitProperties.hitResult.location.index + 1, newSegment); hitProperties.hitResult.segment = newSegment; if (!hitProperties.multiselect) { - clearSelection(); + clearSelection(this.clearSelectedItems); } newSegment.selected = true; @@ -175,7 +179,7 @@ class PointTool { // and delete if (this.deselectOnMouseUp) { if (this.invertDeselect) { - clearSelection(); + clearSelection(this.clearSelectedItems); this.deselectOnMouseUp.selected = true; } else { this.deselectOnMouseUp.selected = false; @@ -188,6 +192,7 @@ class PointTool { this.deleteOnMouseUp = null; } this.selectedItems = null; + this.setSelectedItems(); // @todo add back undo this.onUpdateSvg(); } diff --git a/src/helper/selection-tools/reshape-tool.js b/src/helper/selection-tools/reshape-tool.js index 596ec0f3..efa75158 100644 --- a/src/helper/selection-tools/reshape-tool.js +++ b/src/helper/selection-tools/reshape-tool.js @@ -41,9 +41,11 @@ class ReshapeTool extends paper.Tool { /** * @param {function} setHoveredItem Callback to set the hovered item * @param {function} clearHoveredItem Callback to clear the hovered item + * @param {function} setSelectedItems Callback to set the set of selected items in the Redux state + * @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state * @param {!function} onUpdateSvg A callback to call when the image visibly changes */ - constructor (setHoveredItem, clearHoveredItem, onUpdateSvg) { + constructor (setHoveredItem, clearHoveredItem, setSelectedItems, clearSelectedItems, onUpdateSvg) { super(); this.setHoveredItem = setHoveredItem; this.clearHoveredItem = clearHoveredItem; @@ -52,10 +54,11 @@ class ReshapeTool extends paper.Tool { this.lastEvent = null; this.mode = ReshapeModes.SELECTION_BOX; this._modeMap = {}; - this._modeMap[ReshapeModes.FILL] = new MoveTool(onUpdateSvg); - this._modeMap[ReshapeModes.POINT] = new PointTool(onUpdateSvg); - this._modeMap[ReshapeModes.HANDLE] = new HandleTool(onUpdateSvg); - this._modeMap[ReshapeModes.SELECTION_BOX] = new SelectionBoxTool(Modes.RESHAPE); + this._modeMap[ReshapeModes.FILL] = new MoveTool(setSelectedItems, clearSelectedItems, onUpdateSvg); + this._modeMap[ReshapeModes.POINT] = new PointTool(setSelectedItems, clearSelectedItems, onUpdateSvg); + this._modeMap[ReshapeModes.HANDLE] = new HandleTool(setSelectedItems, clearSelectedItems, onUpdateSvg); + this._modeMap[ReshapeModes.SELECTION_BOX] = + new SelectionBoxTool(Modes.RESHAPE, setSelectedItems, clearSelectedItems); // We have to set these functions instead of just declaring them because // paper.js tools hook up the listeners in the setter functions. diff --git a/src/helper/selection-tools/select-tool.js b/src/helper/selection-tools/select-tool.js index bc33eea3..93fc31cf 100644 --- a/src/helper/selection-tools/select-tool.js +++ b/src/helper/selection-tools/select-tool.js @@ -21,15 +21,17 @@ class SelectTool extends paper.Tool { /** * @param {function} setHoveredItem Callback to set the hovered item * @param {function} clearHoveredItem Callback to clear the hovered item + * @param {function} setSelectedItems Callback to set the set of selected items in the Redux state + * @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state * @param {!function} onUpdateSvg A callback to call when the image visibly changes */ - constructor (setHoveredItem, clearHoveredItem, onUpdateSvg) { + constructor (setHoveredItem, clearHoveredItem, setSelectedItems, clearSelectedItems, onUpdateSvg) { super(); this.setHoveredItem = setHoveredItem; this.clearHoveredItem = clearHoveredItem; this.onUpdateSvg = onUpdateSvg; - this.boundingBoxTool = new BoundingBoxTool(onUpdateSvg); - this.selectionBoxTool = new SelectionBoxTool(Modes.SELECT); + this.boundingBoxTool = new BoundingBoxTool(setSelectedItems, clearSelectedItems, onUpdateSvg); + this.selectionBoxTool = new SelectionBoxTool(Modes.SELECT, setSelectedItems, clearSelectedItems); this.selectionBoxMode = false; this.prevHoveredItemId = null; @@ -42,6 +44,7 @@ class SelectTool extends paper.Tool { this.onKeyUp = this.handleKeyUp; selectRootItem(); + setSelectedItems(); this.boundingBoxTool.setSelectionBounds(); } /** diff --git a/src/helper/selection-tools/selection-box-tool.js b/src/helper/selection-tools/selection-box-tool.js index bc787c51..14ce3eb8 100644 --- a/src/helper/selection-tools/selection-box-tool.js +++ b/src/helper/selection-tools/selection-box-tool.js @@ -3,16 +3,24 @@ import {clearSelection, processRectangularSelection} from '../selection'; /** Tool to handle drag selection. A dotted line box appears and everything enclosed is selected. */ class SelectionBoxTool { - constructor (mode) { + /** + * @param {!Modes} mode Current paint editor mode + * @param {function} setSelectedItems Callback to set the set of selected items in the Redux state + * @param {function} clearSelectedItems Callback to clear the set of selected items in the Redux state + */ + constructor (mode, setSelectedItems, clearSelectedItems) { this.selectionRect = null; this.mode = mode; + this.setSelectedItems = setSelectedItems; + this.clearSelectedItems = clearSelectedItems; } /** * @param {boolean} multiselect Whether to multiselect on mouse down (e.g. shift key held) */ onMouseDown (multiselect) { if (!multiselect) { - clearSelection(); + clearSelection(this.clearSelectedItems); + this.clearSelectedItems(); } } onMouseDrag (event) { @@ -25,6 +33,7 @@ class SelectionBoxTool { processRectangularSelection(event, this.selectionRect, this.mode); this.selectionRect.remove(); this.selectionRect = null; + this.setSelectedItems(); } } } diff --git a/src/helper/selection.js b/src/helper/selection.js index 6d318f1d..2c12b161 100644 --- a/src/helper/selection.js +++ b/src/helper/selection.js @@ -10,7 +10,7 @@ import {getItemsCompoundPath, isCompoundPath, isCompoundPathChild} from './compo * be included in the returned items. * @return {Array} all top-level (direct descendants of a paper.Layer) items */ -const getAllPaperItems = function (includeGuides) { +const getAllRootItems = function (includeGuides) { includeGuides = includeGuides || false; const allItems = []; for (const layer of paper.project.layers) { @@ -29,8 +29,8 @@ const getAllPaperItems = function (includeGuides) { * @return {Array} all top-level (direct descendants of a paper.Layer) items * that aren't guide items or helper items. */ -const getAllSelectableItems = function () { - const allItems = getAllPaperItems(); +const getAllSelectableRootItems = function () { + const allItems = getAllRootItems(); const selectables = []; for (let i = 0; i < allItems.length; i++) { if (allItems[i].data && !allItems[i].data.isHelperItem) { @@ -97,7 +97,7 @@ const setItemSelection = function (item, state, fullySelected) { }; const selectAllItems = function () { - const items = getAllSelectableItems(); + const items = getAllSelectableRootItems(); for (let i = 0; i < items.length; i++) { setItemSelection(items[i], true); @@ -105,51 +105,67 @@ const selectAllItems = function () { }; const selectAllSegments = function () { - const items = getAllSelectableItems(); + const items = getAllSelectableRootItems(); for (let i = 0; i < items.length; i++) { selectItemSegments(items[i], true); } }; -const clearSelection = function () { +/** @param {!function} dispatchClearSelect Function to update the Redux select state */ +const clearSelection = function (dispatchClearSelect) { paper.project.deselectAll(); // @todo: Update toolbar state on change + dispatchClearSelect(); }; -// This gets all selected non-grouped items and groups -// (alternative to paper.project.selectedItems, which includes -// group children in addition to the group) -// Returns in increasing Z order -const getSelectedItems = function (recursive) { +/** + * This gets all selected non-grouped items and groups + * (alternative to paper.project.selectedItems, which includes + * group children in addition to the group) + * @return {Array} in increasing Z order. + */ +const getSelectedRootItems = function () { const allItems = paper.project.selectedItems; const itemsAndGroups = []; - if (recursive) { - for (let i = 0; i < allItems.length; i++) { - const item = allItems[i]; + for (let i = 0; i < allItems.length; i++) { + const item = allItems[i]; + if ((isGroup(item) && !isGroup(item.parent)) || + !isGroup(item.parent)) { if (item.data && !item.data.isSelectionBound) { itemsAndGroups.push(item); } } - } else { - for (let i = 0; i < allItems.length; i++) { - const item = allItems[i]; - if ((isGroup(item) && !isGroup(item.parent)) || - !isGroup(item.parent)) { - if (item.data && !item.data.isSelectionBound) { - itemsAndGroups.push(item); - } - } - } } + // sort items by index (0 at bottom) itemsAndGroups.sort((a, b) => parseFloat(a.index) - parseFloat(b.index)); return itemsAndGroups; }; -const deleteItemSelection = function (recursive) { - const items = getSelectedItems(recursive); +/** + * This gets all selected items that are as deeply nested as possible. Does not + * return the parent groups. + * @return {Array} in increasing Z order. + */ +const getSelectedLeafItems = function () { + const allItems = paper.project.selectedItems; + const items = []; + + for (let i = 0; i < allItems.length; i++) { + const item = allItems[i]; + if (!isGroup(item) && item.data && !item.data.isSelectionBound) { + items.push(item); + } + } + + // sort items by index (0 at bottom) + items.sort((a, b) => parseFloat(a.index) - parseFloat(b.index)); + return items; +}; + +const deleteItemSelection = function (items) { for (let i = 0; i < items.length; i++) { items[i].remove(); } @@ -160,11 +176,10 @@ const deleteItemSelection = function (recursive) { // pg.undo.snapshot('deleteItemSelection'); }; -const removeSelectedSegments = function (recursive) { +const removeSelectedSegments = function (items) { // @todo add back undo // pg.undo.snapshot('removeSelectedSegments'); - const items = getSelectedItems(recursive); const segmentsToRemove = []; for (let i = 0; i < items.length; i++) { @@ -188,12 +203,14 @@ const removeSelectedSegments = function (recursive) { const deleteSelection = function (mode) { if (mode === Modes.RESHAPE) { + const selectedItems = getSelectedLeafItems(); // If there are points selected remove them. If not delete the item selected. - if (!removeSelectedSegments(true /* recursive */)) { - deleteItemSelection(true /* recursive */); + if (!removeSelectedSegments(selectedItems)) { + deleteItemSelection(selectedItems); } } else { - deleteItemSelection(); + const selectedItems = getSelectedRootItems(); + deleteItemSelection(selectedItems); } }; @@ -243,7 +260,7 @@ const splitPathRetainSelection = function (path, index, deselectSplitSegments) { }; const splitPathAtSelectedSegments = function () { - const items = getSelectedItems(); + const items = getSelectedRootItems(); for (let i = 0; i < items.length; i++) { const item = items[i]; const segments = item.segments; @@ -299,9 +316,7 @@ const deleteSegments = function (item) { } }; -const deleteSegmentSelection = function () { - - const items = getSelectedItems(); +const deleteSegmentSelection = function (items) { for (let i = 0; i < items.length; i++) { deleteSegments(items[i]); } @@ -313,7 +328,7 @@ const deleteSegmentSelection = function () { }; const cloneSelection = function (recursive) { - const selectedItems = getSelectedItems(recursive); + const selectedItems = recursive ? getSelectedLeafItems() : getSelectedRootItems(); for (let i = 0; i < selectedItems.length; i++) { const item = selectedItems[i]; item.clone(); @@ -325,7 +340,7 @@ const cloneSelection = function (recursive) { // Only returns paths, no compound paths, groups or any other stuff const getSelectedPaths = function () { - const allPaths = getSelectedItems(); + const allPaths = getSelectedRootItems(); const paths = []; for (let i = 0; i < allPaths.length; i++) { @@ -456,7 +471,7 @@ const _rectangularSelectionGroupLoop = function (group, rect, root, event, mode) * @param {Modes} mode The mode of the paint editor when drawing the rectangle */ const processRectangularSelection = function (event, rect, mode) { - const allItems = getAllSelectableItems(); + const allItems = getAllSelectableRootItems(); for (let i = 0; i < allItems.length; i++) { const item = allItems[i]; @@ -478,7 +493,7 @@ const processRectangularSelection = function (event, rect, mode) { * instead. (otherwise the compound path breaks because of scale-grouping) */ const selectRootItem = function () { - const items = getSelectedItems(true /* recursive */); + const items = getSelectedLeafItems(); for (const item of items) { if (isCompoundPathChild(item)) { const cp = getItemsCompoundPath(item); @@ -492,11 +507,11 @@ const selectRootItem = function () { }; const shouldShowIfSelection = function () { - return getSelectedItems().length > 0; + return getSelectedRootItems().length > 0; }; const shouldShowIfSelectionRecursive = function () { - return getSelectedItems(true /* recursive */).length > 0; + return getSelectedRootItems().length > 0; }; const shouldShowSelectAll = function () { @@ -504,7 +519,7 @@ const shouldShowSelectAll = function () { }; export { - getAllPaperItems, + getAllRootItems, selectAllItems, selectAllSegments, clearSelection, @@ -515,8 +530,9 @@ export { cloneSelection, setItemSelection, setGroupSelection, - getSelectedItems, + getSelectedLeafItems, getSelectedPaths, + getSelectedRootItems, removeSelectedSegments, processRectangularSelection, selectRootItem, diff --git a/src/helper/style-path.js b/src/helper/style-path.js new file mode 100644 index 00000000..6e85b9f6 --- /dev/null +++ b/src/helper/style-path.js @@ -0,0 +1,193 @@ +import {getSelectedLeafItems} from './selection'; +import {isPGTextItem, isPointTextItem} from './item'; +import {isGroup} from './group'; + +const MIXED = 'scratch-paint/style-path/mixed'; + +/** + * Called when setting fill color + * @param {string} colorString New color, css format + */ +const applyFillColorToSelection = function (colorString) { + const items = getSelectedLeafItems(); + for (const item of items) { + if (isPGTextItem(item)) { + for (const child of item.children) { + if (child.children) { + for (const path of child.children) { + if (!path.data.isPGGlyphRect) { + path.fillColor = colorString; + } + } + } else if (!child.data.isPGGlyphRect) { + child.fillColor = colorString; + } + } + } else { + if (isPointTextItem(item) && !colorString) { + colorString = 'rgba(0,0,0,0)'; + } + item.fillColor = colorString; + } + } + // @todo add back undo +}; + +/** + * Called when setting stroke color + * @param {string} colorString New color, css format + */ +const applyStrokeColorToSelection = function (colorString) { + const items = getSelectedLeafItems(); + + for (const item of items) { + if (isPGTextItem(item)) { + if (item.children) { + for (const child of item.children) { + if (child.children) { + for (const path of child.children) { + if (!path.data.isPGGlyphRect) { + path.strokeColor = colorString; + } + } + } else if (!child.data.isPGGlyphRect) { + child.strokeColor = colorString; + } + } + } else if (!item.data.isPGGlyphRect) { + item.strokeColor = colorString; + } + } else { + item.strokeColor = colorString; + } + } + // @todo add back undo +}; + +/** + * Called when setting stroke width + * @param {number} value New stroke width + */ +const applyStrokeWidthToSelection = function (value) { + const items = getSelectedLeafItems(); + for (const item of items) { + if (isGroup(item)) { + continue; + } else { + item.strokeWidth = value; + } + } + // @todo add back undo +}; + +/** + * Get state of colors and stroke width for selection + * @param {!Array} selectedItems Selected paper items + * @return {object} Object of strokeColor, strokeWidth, fillColor of the selection. + * Gives MIXED when there are mixed values for a color, and null for transparent. + * Gives null when there are mixed values for stroke width. + */ +const getColorsFromSelection = function (selectedItems) { + let selectionFillColorString; + let selectionStrokeColorString; + let selectionStrokeWidth; + let firstChild = true; + + for (const item of selectedItems) { + let itemFillColorString; + let itemStrokeColorString; + + // handle pgTextItems differently by going through their children + if (isPGTextItem(item)) { + for (const child of item.children) { + for (const path of child.children) { + if (!path.data.isPGGlyphRect) { + if (path.fillColor) { + itemFillColorString = path.fillColor.toCSS(); + } + if (path.strokeColor) { + itemStrokeColorString = path.strokeColor.toCSS(); + } + // check every style against the first of the items + if (firstChild) { + firstChild = false; + selectionFillColorString = itemFillColorString; + selectionStrokeColorString = itemStrokeColorString; + selectionStrokeWidth = path.strokeWidth; + } + if (itemFillColorString !== selectionFillColorString) { + selectionFillColorString = MIXED; + } + if (itemStrokeColorString !== selectionStrokeColorString) { + selectionStrokeColorString = MIXED; + } + if (selectionStrokeWidth !== path.strokeWidth) { + selectionStrokeWidth = null; + } + } + } + } + } else if (!isGroup(item)) { + if (item.fillColor) { + // hack bc text items with null fill can't be detected by fill-hitTest anymore + if (isPointTextItem(item) && item.fillColor.toCSS() === 'rgba(0,0,0,0)') { + itemFillColorString = null; + } else { + itemFillColorString = item.fillColor.toCSS(); + } + } + if (item.strokeColor) { + itemStrokeColorString = item.strokeColor.toCSS(); + } + // check every style against the first of the items + if (firstChild) { + firstChild = false; + selectionFillColorString = itemFillColorString; + selectionStrokeColorString = itemStrokeColorString; + selectionStrokeWidth = item.strokeWidth; + } + if (itemFillColorString !== selectionFillColorString) { + selectionFillColorString = MIXED; + } + if (itemStrokeColorString !== selectionStrokeColorString) { + selectionStrokeColorString = MIXED; + } + if (selectionStrokeWidth !== item.strokeWidth) { + selectionStrokeWidth = null; + } + } + } + return { + fillColor: selectionFillColorString ? selectionFillColorString : null, + strokeColor: selectionStrokeColorString ? selectionStrokeColorString : null, + strokeWidth: selectionStrokeWidth || (selectionStrokeWidth === null) ? selectionStrokeWidth : 0 + }; +}; + +const stylePath = function (path, options) { + if (options.isEraser) { + path.fillColor = 'white'; + } else { + path.fillColor = options.fillColor; + } +}; + +const styleCursorPreview = function (path, options) { + if (options.isEraser) { + path.fillColor = 'white'; + path.strokeColor = 'cornflowerblue'; + path.strokeWidth = 1; + } else { + path.fillColor = options.fillColor; + } +}; + +export { + applyFillColorToSelection, + applyStrokeColorToSelection, + applyStrokeWidthToSelection, + getColorsFromSelection, + MIXED, + stylePath, + styleCursorPreview +}; diff --git a/src/reducers/fill-color.js b/src/reducers/fill-color.js index 74bf4d6c..fafa3016 100644 --- a/src/reducers/fill-color.js +++ b/src/reducers/fill-color.js @@ -1,4 +1,6 @@ import log from '../log/log'; +import {CHANGE_SELECTED_ITEMS} from './selected-items'; +import {getColorsFromSelection} from '../helper/style-path'; const CHANGE_FILL_COLOR = 'scratch-paint/fill-color/CHANGE_FILL_COLOR'; const initialState = '#000'; @@ -14,6 +16,12 @@ const reducer = function (state, action) { 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).fillColor; default: return state; } diff --git a/src/reducers/scratch-paint-reducer.js b/src/reducers/scratch-paint-reducer.js index 0d44903b..2ac4a2ee 100644 --- a/src/reducers/scratch-paint-reducer.js +++ b/src/reducers/scratch-paint-reducer.js @@ -4,11 +4,13 @@ import brushModeReducer from './brush-mode'; import eraserModeReducer from './eraser-mode'; import colorReducer from './color'; import hoverReducer from './hover'; +import selectedItemReducer from './selected-items'; export default combineReducers({ mode: modeReducer, brushMode: brushModeReducer, eraserMode: eraserModeReducer, color: colorReducer, - hoveredItemId: hoverReducer + hoveredItemId: hoverReducer, + selectedItems: selectedItemReducer }); diff --git a/src/reducers/selected-items.js b/src/reducers/selected-items.js new file mode 100644 index 00000000..67a2d147 --- /dev/null +++ b/src/reducers/selected-items.js @@ -0,0 +1,53 @@ +import log from '../log/log'; +const CHANGE_SELECTED_ITEMS = 'scratch-paint/select/CHANGE_SELECTED_ITEMS'; +const initialState = []; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case CHANGE_SELECTED_ITEMS: + if (!action.selectedItems || !(action.selectedItems instanceof Array)) { + log.warn(`No selected items or wrong format provided: ${action.selectedItems}`); + return state; + } + // If they are not equal, return the new list of items. Else return old list + if (action.selectedItems.length !== state.length) { + return action.selectedItems; + } + // Shallow equality check (we may need to update this later for more granularity) + for (let i = 0; i < action.selectedItems.length; i++) { + if (action.selectedItems[i] !== state[i]) { + return action.selectedItems; + } + } + return state; + default: + return state; + } +}; + +// Action creators ================================== +/** + * Set the selected item state to the given array of items + * @param {Array} selectedItems from paper.project.selectedItems + * @return {object} Redux action to change the selected items. + */ +const setSelectedItems = function (selectedItems) { + return { + type: CHANGE_SELECTED_ITEMS, + selectedItems: selectedItems + }; +}; +const clearSelectedItems = function () { + return { + type: CHANGE_SELECTED_ITEMS, + selectedItems: [] + }; +}; + +export { + reducer as default, + setSelectedItems, + clearSelectedItems, + CHANGE_SELECTED_ITEMS +}; diff --git a/src/reducers/stroke-color.js b/src/reducers/stroke-color.js index 15efc21e..a7ecba9e 100644 --- a/src/reducers/stroke-color.js +++ b/src/reducers/stroke-color.js @@ -1,4 +1,6 @@ import log from '../log/log'; +import {CHANGE_SELECTED_ITEMS} from './selected-items'; +import {getColorsFromSelection} from '../helper/style-path'; const CHANGE_STROKE_COLOR = 'scratch-paint/stroke-color/CHANGE_STROKE_COLOR'; const initialState = '#000'; @@ -14,6 +16,12 @@ const reducer = function (state, action) { return state; } return action.strokeColor; + case CHANGE_SELECTED_ITEMS: + // Don't change state if no selection + if (!action.selectedItems || !action.selectedItems.length) { + return state; + } + return getColorsFromSelection(action.selectedItems).strokeColor; default: return state; } diff --git a/src/reducers/stroke-width.js b/src/reducers/stroke-width.js index 615cab12..43213b8d 100644 --- a/src/reducers/stroke-width.js +++ b/src/reducers/stroke-width.js @@ -1,4 +1,6 @@ import log from '../log/log'; +import {CHANGE_SELECTED_ITEMS} from './selected-items'; +import {getColorsFromSelection} from '../helper/style-path'; const CHANGE_STROKE_WIDTH = 'scratch-paint/stroke-width/CHANGE_STROKE_WIDTH'; const MAX_STROKE_WIDTH = 400; @@ -13,6 +15,12 @@ const reducer = function (state, action) { return state; } return Math.min(MAX_STROKE_WIDTH, Math.max(0, action.strokeWidth)); + case CHANGE_SELECTED_ITEMS: + // Don't change state if no selection + if (!action.selectedItems || !action.selectedItems.length) { + return state; + } + return getColorsFromSelection(action.selectedItems).strokeWidth; default: return state; } diff --git a/test/__mocks__/paperMocks.js b/test/__mocks__/paperMocks.js new file mode 100644 index 00000000..b8d9ff15 --- /dev/null +++ b/test/__mocks__/paperMocks.js @@ -0,0 +1,23 @@ +/** + * Pretend paper.Item whose parent is a layer. + * @param {object} options Item params + * @param {string} options.strokeColor Value to return for the item's stroke color + * @param {string} options.fillColor Value to return for the item's fill color + * @param {string} options.strokeWidth Value to return for the item's stroke width + * @return {object} mock item + */ +const mockPaperRootItem = function (options) { + return { + strokeColor: {toCSS: function () { + return options.strokeColor; + }}, + fillColor: {toCSS: function () { + return options.fillColor; + }}, + strokeWidth: options.strokeWidth, + parent: {className: 'Layer'}, + data: {} + }; +}; + +export {mockPaperRootItem}; diff --git a/test/unit/fill-color-reducer.test.js b/test/unit/fill-color-reducer.test.js index dddd9008..fb830431 100644 --- a/test/unit/fill-color-reducer.test.js +++ b/test/unit/fill-color-reducer.test.js @@ -1,6 +1,9 @@ /* 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; @@ -26,6 +29,22 @@ test('changeFillColor', () => { .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'; diff --git a/test/unit/selected-items-reducer.test.js b/test/unit/selected-items-reducer.test.js new file mode 100644 index 00000000..d4a30ba8 --- /dev/null +++ b/test/unit/selected-items-reducer.test.js @@ -0,0 +1,47 @@ +/* eslint-env jest */ +import selectedItemsReducer from '../../src/reducers/selected-items'; +import {setSelectedItems, clearSelectedItems} from '../../src/reducers/selected-items'; + +test('initialState', () => { + let defaultState; + + expect(selectedItemsReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeDefined(); +}); + +test('setSelectedItems', () => { + let defaultState; + + const newSelected1 = ['selected1', 'selected2']; + const newSelected2 = ['selected1', 'selected3']; + const unselected = []; + expect(selectedItemsReducer(defaultState /* state */, setSelectedItems(newSelected1) /* action */)) + .toEqual(newSelected1); + expect(selectedItemsReducer(newSelected1, setSelectedItems(newSelected2) /* action */)) + .toEqual(newSelected2); + expect(selectedItemsReducer(newSelected1, setSelectedItems(unselected) /* action */)) + .toEqual(unselected); + expect(selectedItemsReducer(defaultState, setSelectedItems(unselected) /* action */)) + .toEqual(unselected); +}); + +test('clearSelectedItems', () => { + let defaultState; + + const selectedState = ['selected1', 'selected2']; + const unselectedState = []; + expect(selectedItemsReducer(defaultState /* state */, clearSelectedItems() /* action */)) + .toHaveLength(0); + expect(selectedItemsReducer(selectedState /* state */, clearSelectedItems() /* action */)) + .toHaveLength(0); + expect(selectedItemsReducer(unselectedState /* state */, clearSelectedItems() /* action */)) + .toHaveLength(0); +}); + +test('invalidsetSelectedItems', () => { + const origState = ['selected1', 'selected2']; + + expect(selectedItemsReducer(origState /* state */, setSelectedItems() /* action */)) + .toBe(origState); + expect(selectedItemsReducer(origState /* state */, setSelectedItems('notAnArray') /* action */)) + .toBe(origState); +}); diff --git a/test/unit/stroke-color-reducer.test.js b/test/unit/stroke-color-reducer.test.js index 7f812299..e823d2e1 100644 --- a/test/unit/stroke-color-reducer.test.js +++ b/test/unit/stroke-color-reducer.test.js @@ -1,6 +1,9 @@ /* 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; @@ -26,6 +29,22 @@ test('changeStrokeColor', () => { .toEqual(newStrokeColor); }); +test('changeStrokeColorViaSelectedItems', () => { + let defaultState; + + const strokeColor1 = 6; + const strokeColor2 = null; // transparent + let selectedItems = [mockPaperRootItem({strokeColor: strokeColor1})]; + expect(strokeColorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) + .toEqual(strokeColor1); + selectedItems = [mockPaperRootItem({strokeColor: strokeColor2})]; + expect(strokeColorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) + .toEqual(strokeColor2); + selectedItems = [mockPaperRootItem({strokeColor: strokeColor1}), mockPaperRootItem({strokeColor: strokeColor2})]; + expect(strokeColorReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) + .toEqual(MIXED); +}); + test('invalidChangeStrokeColor', () => { const origState = '#fff'; diff --git a/test/unit/stroke-width-reducer.test.js b/test/unit/stroke-width-reducer.test.js index a2189049..03fd9184 100644 --- a/test/unit/stroke-width-reducer.test.js +++ b/test/unit/stroke-width-reducer.test.js @@ -1,6 +1,8 @@ /* eslint-env jest */ import strokeWidthReducer from '../../src/reducers/stroke-width'; import {MAX_STROKE_WIDTH, changeStrokeWidth} from '../../src/reducers/stroke-width'; +import {setSelectedItems} from '../../src/reducers/selected-items'; +import {mockPaperRootItem} from '../__mocks__/paperMocks'; test('initialState', () => { let defaultState; @@ -23,6 +25,22 @@ test('changestrokeWidth', () => { .toEqual(MAX_STROKE_WIDTH); }); +test('changeStrokeWidthViaSelectedItems', () => { + let defaultState; + + const strokeWidth1 = 6; + let strokeWidth2; // no outline + let selectedItems = [mockPaperRootItem({strokeWidth: strokeWidth1})]; + expect(strokeWidthReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) + .toEqual(strokeWidth1); + selectedItems = [mockPaperRootItem({strokeWidth: strokeWidth2})]; + expect(strokeWidthReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) + .toEqual(0); // Convert no outline to stroke width 0 + selectedItems = [mockPaperRootItem({strokeWidth: strokeWidth1}), mockPaperRootItem({strokeWidth: strokeWidth2})]; + expect(strokeWidthReducer(defaultState /* state */, setSelectedItems(selectedItems) /* action */)) + .toEqual(null); // null indicates mixed for stroke width +}); + test('invalidChangestrokeWidth', () => { const origState = {strokeWidth: 1};