diff --git a/src/components/paint-editor.jsx b/src/components/paint-editor.jsx index da5fcd3f..f799b9df 100644 --- a/src/components/paint-editor.jsx +++ b/src/components/paint-editor.jsx @@ -58,11 +58,13 @@ class PaintEditorComponent extends React.Component {
@@ -101,11 +103,17 @@ class PaintEditorComponent extends React.Component { {/* Second Row */}
{/* fill */} - + {/* stroke */} - + {/* stroke width */} - +
Mode tools @@ -154,6 +162,8 @@ class PaintEditorComponent extends React.Component { PaintEditorComponent.propTypes = { intl: intlShape, + onRedo: PropTypes.func.isRequired, + onUndo: PropTypes.func.isRequired, onUpdateSvg: PropTypes.func.isRequired, rotationCenterX: PropTypes.number, rotationCenterY: PropTypes.number, diff --git a/src/containers/blob/blob.js b/src/containers/blob/blob.js index 3025be8d..3d93ebec 100644 --- a/src/containers/blob/blob.js +++ b/src/containers/blob/blob.js @@ -4,6 +4,7 @@ import BroadBrushHelper from './broad-brush-helper'; import SegmentBrushHelper from './segment-brush-helper'; import {MIXED, styleCursorPreview} from '../../helper/style-path'; import {clearSelection} from '../../helper/selection'; +import {getGuideLayer} from '../../helper/layer'; /** * Shared code for the brush and eraser mode. Adds functions on the paper tool object @@ -26,13 +27,13 @@ class Blobbiness { } /** - * @param {function} updateCallback call when the drawing has changed to let listeners know + * @param {function} onUpdateSvg 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, clearSelectedItems) { + constructor (onUpdateSvg, clearSelectedItems) { this.broadBrushHelper = new BroadBrushHelper(); this.segmentBrushHelper = new SegmentBrushHelper(); - this.updateCallback = updateCallback; + this.onUpdateSvg = onUpdateSvg; this.clearSelectedItems = clearSelectedItems; // The following are stored to check whether these have changed and the cursor preview needs to be redrawn. @@ -143,7 +144,7 @@ class Blobbiness { } blob.cursorPreview.visible = false; - blob.updateCallback(); + blob.onUpdateSvg(); blob.cursorPreview.visible = true; blob.cursorPreview.bringToFront(); blob.cursorPreview.position = event.point; @@ -166,7 +167,7 @@ class Blobbiness { this.cursorPreviewLastPoint = point; } - if (this.cursorPreview && + if (this.cursorPreview && this.cursorPreview.parent && this.brushSize === this.options.brushSize && this.fillColor === this.options.fillColor && this.strokeColor === this.options.strokeColor) { @@ -176,6 +177,8 @@ class Blobbiness { center: point, radius: this.options.brushSize / 2 }); + newPreview.parent = getGuideLayer(); + newPreview.data.isHelperItem = true; if (this.cursorPreview) { this.cursorPreview.remove(); } @@ -234,8 +237,6 @@ class Blobbiness { paths.splice(i, 1); } } - // TODO: Add back undo - // pg.undo.snapshot('broadbrush'); } mergeEraser (lastPath) { @@ -284,8 +285,6 @@ class Blobbiness { } } lastPath.remove(); - // TODO add back undo - // pg.undo.snapshot('eraser'); continue; } // Erase @@ -358,8 +357,6 @@ class Blobbiness { items[i].remove(); } lastPath.remove(); - // TODO: Add back undo handling - // pg.undo.snapshot('eraser'); } colorMatch (existingPath, addedPath) { diff --git a/src/containers/brush-mode.jsx b/src/containers/brush-mode.jsx index b7027c14..b4f899ab 100644 --- a/src/containers/brush-mode.jsx +++ b/src/containers/brush-mode.jsx @@ -4,10 +4,12 @@ import {connect} from 'react-redux'; import bindAll from 'lodash.bindall'; 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'; class BrushMode extends React.Component { @@ -18,7 +20,8 @@ class BrushMode extends React.Component { 'deactivateTool', 'onScroll' ]); - this.blob = new Blobbiness(this.props.onUpdateSvg, this.props.clearSelectedItems); + this.blob = new Blobbiness( + this.props.onUpdateSvg, this.props.clearSelectedItems); } componentDidMount () { if (this.props.isBrushModeActive) { diff --git a/src/containers/eraser-mode.jsx b/src/containers/eraser-mode.jsx index 423058c2..efd920fe 100644 --- a/src/containers/eraser-mode.jsx +++ b/src/containers/eraser-mode.jsx @@ -17,7 +17,8 @@ class EraserMode extends React.Component { 'deactivateTool', 'onScroll' ]); - this.blob = new Blobbiness(this.props.onUpdateSvg, this.props.clearSelectedItems); + this.blob = new Blobbiness( + this.props.onUpdateSvg, this.props.clearSelectedItems); } componentDidMount () { if (this.props.isEraserModeActive) { diff --git a/src/containers/fill-color-indicator.jsx b/src/containers/fill-color-indicator.jsx index 35611f45..904071a8 100644 --- a/src/containers/fill-color-indicator.jsx +++ b/src/containers/fill-color-indicator.jsx @@ -1,19 +1,48 @@ import {connect} from 'react-redux'; +import PropTypes from 'prop-types'; +import React from 'react'; +import bindAll from 'lodash.bindall'; import {changeFillColor} from '../reducers/fill-color'; import FillColorIndicatorComponent from '../components/fill-color-indicator.jsx'; import {applyFillColorToSelection} from '../helper/style-path'; +class FillColorIndicator extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleChangeFillColor' + ]); + } + handleChangeFillColor (newColor) { + applyFillColorToSelection(newColor, this.props.onUpdateSvg); + this.props.onChangeFillColor(newColor); + } + render () { + return ( + + ); + } +} + const mapStateToProps = state => ({ fillColor: state.scratchPaint.color.fillColor }); const mapDispatchToProps = dispatch => ({ onChangeFillColor: fillColor => { - applyFillColorToSelection(fillColor); dispatch(changeFillColor(fillColor)); } }); +FillColorIndicator.propTypes = { + fillColor: PropTypes.string, + onChangeFillColor: PropTypes.func.isRequired, + onUpdateSvg: PropTypes.func.isRequired +}; + export default connect( mapStateToProps, mapDispatchToProps -)(FillColorIndicatorComponent); +)(FillColorIndicator); diff --git a/src/containers/line-mode.jsx b/src/containers/line-mode.jsx index 69356d62..56cf6dc2 100644 --- a/src/containers/line-mode.jsx +++ b/src/containers/line-mode.jsx @@ -1,15 +1,16 @@ +import paper from 'paper'; import PropTypes from 'prop-types'; import React from 'react'; import {connect} from 'react-redux'; import bindAll from 'lodash.bindall'; import Modes from '../modes/modes'; -import {changeStrokeWidth} from '../reducers/stroke-width'; 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'; +import {changeStrokeWidth} from '../reducers/stroke-width'; +import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; + +import LineModeComponent from '../components/line-mode.jsx'; class LineMode extends React.Component { static get SNAP_TOLERANCE () { @@ -206,13 +207,11 @@ class LineMode extends React.Component { } this.hitResult = null; } - this.props.onUpdateSvg(); + this.props.setSelectedItems(); - - // TODO add back undo - // if (this.path) { - // pg.undo.snapshot('line'); - // } + if (this.path) { + this.props.onUpdateSvg(); + } } toleranceSquared () { return Math.pow(LineMode.SNAP_TOLERANCE / paper.view.zoom, 2); diff --git a/src/containers/paint-editor.jsx b/src/containers/paint-editor.jsx index 9d48fa2d..c5154c0f 100644 --- a/src/containers/paint-editor.jsx +++ b/src/containers/paint-editor.jsx @@ -1,8 +1,13 @@ import PropTypes from 'prop-types'; import React from 'react'; import PaintEditorComponent from '../components/paint-editor.jsx'; + import {changeMode} from '../reducers/modes'; +import {undo, redo, undoSnapshot} from '../reducers/undo'; + import {getGuideLayer} from '../helper/layer'; +import {performUndo, performRedo, performSnapshot} from '../helper/undo'; + import Modes from '../modes/modes'; import {connect} from 'react-redux'; import bindAll from 'lodash.bindall'; @@ -12,7 +17,9 @@ class PaintEditor extends React.Component { constructor (props) { super(props); bindAll(this, [ - 'handleUpdateSvg' + 'handleUpdateSvg', + 'handleUndo', + 'handleRedo' ]); } componentDidMount () { @@ -21,7 +28,7 @@ class PaintEditor extends React.Component { componentWillUnmount () { document.removeEventListener('keydown', this.props.onKeyPress); } - handleUpdateSvg () { + handleUpdateSvg (skipSnapshot) { // Hide bounding box getGuideLayer().visible = false; const bounds = paper.project.activeLayer.bounds; @@ -32,14 +39,25 @@ class PaintEditor extends React.Component { }), paper.project.view.center.x - bounds.x, paper.project.view.center.y - bounds.y); + if (!skipSnapshot) { + performSnapshot(this.props.undoSnapshot); + } getGuideLayer().visible = true; } + handleUndo () { + performUndo(this.props.undoState, this.props.onUndo, this.handleUpdateSvg); + } + handleRedo () { + performRedo(this.props.undoState, this.props.onRedo, this.handleUpdateSvg); + } render () { return ( ); @@ -48,12 +66,22 @@ class PaintEditor extends React.Component { PaintEditor.propTypes = { onKeyPress: PropTypes.func.isRequired, + onRedo: PropTypes.func.isRequired, + onUndo: PropTypes.func.isRequired, onUpdateSvg: PropTypes.func.isRequired, rotationCenterX: PropTypes.number, rotationCenterY: PropTypes.number, - svg: PropTypes.string + svg: PropTypes.string, + undoSnapshot: PropTypes.func.isRequired, + undoState: PropTypes.shape({ + stack: PropTypes.arrayOf(PropTypes.object).isRequired, + pointer: PropTypes.number.isRequired + }) }; +const mapStateToProps = state => ({ + undoState: state.scratchPaint.undo +}); const mapDispatchToProps = dispatch => ({ onKeyPress: event => { if (event.key === 'e') { @@ -65,10 +93,19 @@ const mapDispatchToProps = dispatch => ({ } else if (event.key === 's') { dispatch(changeMode(Modes.SELECT)); } + }, + onUndo: () => { + dispatch(undo()); + }, + onRedo: () => { + dispatch(redo()); + }, + undoSnapshot: snapshot => { + dispatch(undoSnapshot(snapshot)); } }); export default connect( - null, + mapStateToProps, mapDispatchToProps )(PaintEditor); diff --git a/src/containers/paper-canvas.jsx b/src/containers/paper-canvas.jsx index ae4eda24..030eaec4 100644 --- a/src/containers/paper-canvas.jsx +++ b/src/containers/paper-canvas.jsx @@ -1,8 +1,12 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import React from 'react'; +import {connect} from 'react-redux'; import paper from 'paper'; +import {performSnapshot} from '../helper/undo'; +import {undoSnapshot} from '../reducers/undo'; + import styles from './paper-canvas.css'; class PaperCanvas extends React.Component { @@ -20,6 +24,7 @@ class PaperCanvas extends React.Component { if (this.props.svg) { this.importSvg(this.props.svg, this.props.rotationCenterX, this.props.rotationCenterY); } + performSnapshot(this.props.undoSnapshot); } componentWillReceiveProps (newProps) { paper.project.activeLayer.removeChildren(); @@ -85,7 +90,16 @@ PaperCanvas.propTypes = { canvasRef: PropTypes.func, rotationCenterX: PropTypes.number, rotationCenterY: PropTypes.number, - svg: PropTypes.string + svg: PropTypes.string, + undoSnapshot: PropTypes.func.isRequired }; +const mapDispatchToProps = dispatch => ({ + undoSnapshot: snapshot => { + dispatch(undoSnapshot(snapshot)); + } +}); -export default PaperCanvas; +export default connect( + null, + mapDispatchToProps +)(PaperCanvas); diff --git a/src/containers/reshape-mode.jsx b/src/containers/reshape-mode.jsx index 1683874c..bffb65bd 100644 --- a/src/containers/reshape-mode.jsx +++ b/src/containers/reshape-mode.jsx @@ -45,7 +45,8 @@ class ReshapeMode extends React.Component { this.props.clearHoveredItem, this.props.setSelectedItems, this.props.clearSelectedItems, - this.props.onUpdateSvg); + this.props.onUpdateSvg + ); this.tool.setPrevHoveredItemId(this.props.hoveredItemId); this.tool.activate(); } diff --git a/src/containers/select-mode.jsx b/src/containers/select-mode.jsx index 505bf412..572aa12a 100644 --- a/src/containers/select-mode.jsx +++ b/src/containers/select-mode.jsx @@ -45,7 +45,8 @@ class SelectMode extends React.Component { this.props.clearHoveredItem, this.props.setSelectedItems, this.props.clearSelectedItems, - this.props.onUpdateSvg); + this.props.onUpdateSvg + ); this.tool.activate(); } deactivateTool () { diff --git a/src/containers/stroke-color-indicator.jsx b/src/containers/stroke-color-indicator.jsx index f7ffcbab..74619b76 100644 --- a/src/containers/stroke-color-indicator.jsx +++ b/src/containers/stroke-color-indicator.jsx @@ -1,19 +1,48 @@ 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 StrokeColorIndicatorComponent from '../components/stroke-color-indicator.jsx'; import {applyStrokeColorToSelection} from '../helper/style-path'; +class StrokeColorIndicator extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleChangeStrokeColor' + ]); + } + handleChangeStrokeColor (newColor) { + applyStrokeColorToSelection(newColor, this.props.onUpdateSvg); + this.props.onChangeStrokeColor(newColor); + } + render () { + return ( + + ); + } +} + const mapStateToProps = state => ({ strokeColor: state.scratchPaint.color.strokeColor }); const mapDispatchToProps = dispatch => ({ onChangeStrokeColor: strokeColor => { - applyStrokeColorToSelection(strokeColor); dispatch(changeStrokeColor(strokeColor)); } }); +StrokeColorIndicator.propTypes = { + onChangeStrokeColor: PropTypes.func.isRequired, + onUpdateSvg: PropTypes.func.isRequired, + strokeColor: PropTypes.string +}; + export default connect( mapStateToProps, mapDispatchToProps -)(StrokeColorIndicatorComponent); +)(StrokeColorIndicator); diff --git a/src/containers/stroke-width-indicator.jsx b/src/containers/stroke-width-indicator.jsx index d1b0def3..fe836b8a 100644 --- a/src/containers/stroke-width-indicator.jsx +++ b/src/containers/stroke-width-indicator.jsx @@ -1,19 +1,48 @@ import {connect} from 'react-redux'; +import PropTypes from 'prop-types'; +import React from 'react'; +import bindAll from 'lodash.bindall'; import {changeStrokeWidth} from '../reducers/stroke-width'; import StrokeWidthIndicatorComponent from '../components/stroke-width-indicator.jsx'; import {applyStrokeWidthToSelection} from '../helper/style-path'; +class StrokeWidthIndicator extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleChangeStrokeWidth' + ]); + } + handleChangeStrokeWidth (newWidth) { + applyStrokeWidthToSelection(newWidth, this.props.onUpdateSvg); + this.props.onChangeStrokeWidth(newWidth); + } + render () { + return ( + + ); + } +} + const mapStateToProps = state => ({ strokeWidth: state.scratchPaint.color.strokeWidth }); const mapDispatchToProps = dispatch => ({ onChangeStrokeWidth: strokeWidth => { - applyStrokeWidthToSelection(strokeWidth); dispatch(changeStrokeWidth(strokeWidth)); } }); +StrokeWidthIndicator.propTypes = { + onChangeStrokeWidth: PropTypes.func.isRequired, + onUpdateSvg: PropTypes.func.isRequired, + strokeWidth: PropTypes.number +}; + export default connect( mapStateToProps, mapDispatchToProps -)(StrokeWidthIndicatorComponent); +)(StrokeWidthIndicator); diff --git a/src/helper/selection-tools/handle-tool.js b/src/helper/selection-tools/handle-tool.js index c3d26a1d..62e0b345 100644 --- a/src/helper/selection-tools/handle-tool.js +++ b/src/helper/selection-tools/handle-tool.js @@ -12,6 +12,7 @@ class HandleTool { this.setSelectedItems = setSelectedItems; this.clearSelectedItems = clearSelectedItems; this.onUpdateSvg = onUpdateSvg; + this.selectedItems = []; } /** * @param {!object} hitProperties Describes the mouse event @@ -28,9 +29,9 @@ class HandleTool { this.hitType = hitProperties.hitResult.type; } onMouseDrag (event) { - const selectedItems = getSelectedLeafItems(); + this.selectedItems = getSelectedLeafItems(); - for (const item of selectedItems) { + for (const item of this.selectedItems) { for (const seg of item.segments) { // add the point of the segment before the drag started // for later use in the snap calculation @@ -66,8 +67,23 @@ class HandleTool { } } onMouseUp () { - // @todo add back undo - this.onUpdateSvg(); + // resetting the items and segments origin points for the next usage + let moved = false; + for (const item of this.selectedItems) { + if (!item.segments) { + return; + } + for (const seg of item.segments) { + if (seg.origPoint && !seg.equals(seg.origPoint)) { + moved = true; + } + seg.origPoint = null; + } + } + if (moved) { + this.onUpdateSvg(); + } + this.selectedItems = []; } } diff --git a/src/helper/selection-tools/move-tool.js b/src/helper/selection-tools/move-tool.js index 0a16277b..6e39673d 100644 --- a/src/helper/selection-tools/move-tool.js +++ b/src/helper/selection-tools/move-tool.js @@ -51,7 +51,7 @@ class MoveTool { } this._select(item, true, hitProperties.subselect); } - if (hitProperties.clone) cloneSelection(hitProperties.subselect); + if (hitProperties.clone) cloneSelection(hitProperties.subselect, this.onUpdateSvg); this.selectedItems = getSelectedLeafItems(); } /** @@ -94,15 +94,19 @@ class MoveTool { } } onMouseUp () { + let moved = false; // resetting the items origin point for the next usage for (const item of this.selectedItems) { + if (item.data.origPos && !item.position.equals(item.data.origPos)) { + moved = true; + } item.data.origPos = null; } this.selectedItems = null; - // @todo add back undo - // pg.undo.snapshot('moveSelection'); - this.onUpdateSvg(); + if (moved) { + this.onUpdateSvg(); + } } } diff --git a/src/helper/selection-tools/point-tool.js b/src/helper/selection-tools/point-tool.js index 9b54cb7e..f964f2eb 100644 --- a/src/helper/selection-tools/point-tool.js +++ b/src/helper/selection-tools/point-tool.js @@ -166,11 +166,15 @@ class PointTool { } onMouseUp () { // resetting the items and segments origin points for the next usage + let moved = false; for (const item of this.selectedItems) { if (!item.segments) { return; } for (const seg of item.segments) { + if (seg.origPoint && !seg.equals(seg.origPoint)) { + moved = true; + } seg.origPoint = null; } } @@ -193,8 +197,9 @@ class PointTool { } this.selectedItems = null; this.setSelectedItems(); - // @todo add back undo - this.onUpdateSvg(); + if (moved) { + this.onUpdateSvg(); + } } } diff --git a/src/helper/selection-tools/reshape-tool.js b/src/helper/selection-tools/reshape-tool.js index efa75158..38ecebae 100644 --- a/src/helper/selection-tools/reshape-tool.js +++ b/src/helper/selection-tools/reshape-tool.js @@ -221,8 +221,7 @@ class ReshapeTool extends paper.Tool { handleKeyUp (event) { // Backspace, delete if (event.key === 'delete' || event.key === 'backspace') { - deleteSelection(Modes.RESHAPE); - this.onUpdateSvg(); + deleteSelection(Modes.RESHAPE, this.onUpdateSvg); } } deactivateTool () { diff --git a/src/helper/selection-tools/rotate-tool.js b/src/helper/selection-tools/rotate-tool.js index 2006cebf..902eaf36 100644 --- a/src/helper/selection-tools/rotate-tool.js +++ b/src/helper/selection-tools/rotate-tool.js @@ -63,7 +63,6 @@ class RotateTool { this.rotGroupPivot = null; this.prevRot = []; - // @todo add back undo this.onUpdateSvg(); } } diff --git a/src/helper/selection-tools/scale-tool.js b/src/helper/selection-tools/scale-tool.js index 8744ab72..e58e7bd9 100644 --- a/src/helper/selection-tools/scale-tool.js +++ b/src/helper/selection-tools/scale-tool.js @@ -157,7 +157,6 @@ class ScaleTool { } this.itemGroup.remove(); - // @todo add back undo this.onUpdateSvg(); } _getRectCornerNameByIndex (index) { diff --git a/src/helper/selection-tools/select-tool.js b/src/helper/selection-tools/select-tool.js index 93fc31cf..14f9e5b2 100644 --- a/src/helper/selection-tools/select-tool.js +++ b/src/helper/selection-tools/select-tool.js @@ -126,10 +126,9 @@ class SelectTool extends paper.Tool { handleKeyUp (event) { // Backspace, delete if (event.key === 'delete' || event.key === 'backspace') { - deleteSelection(Modes.SELECT); + deleteSelection(Modes.SELECT, this.onUpdateSvg); this.clearHoveredItem(); this.boundingBoxTool.removeBoundsPath(); - this.onUpdateSvg(); } } deactivateTool () { diff --git a/src/helper/selection.js b/src/helper/selection.js index 2c12b161..95c55984 100644 --- a/src/helper/selection.js +++ b/src/helper/selection.js @@ -57,7 +57,7 @@ const selectItemSegments = function (item, state) { } }; -const setGroupSelection = function (root, selected, fullySelected) { +const _setGroupSelection = function (root, selected, fullySelected) { root.fullySelected = fullySelected; root.selected = selected; // select children of compound-path or group @@ -66,7 +66,7 @@ const setGroupSelection = function (root, selected, fullySelected) { if (children) { for (const child of children) { if (isGroup(child)) { - setGroupSelection(child, selected, fullySelected); + _setGroupSelection(child, selected, fullySelected); } else { child.fullySelected = fullySelected; child.selected = selected; @@ -85,12 +85,12 @@ const setItemSelection = function (item, state, fullySelected) { // do it recursive setItemSelection(parentGroup, state, fullySelected); } else if (itemsCompoundPath) { - setGroupSelection(itemsCompoundPath, state, fullySelected); + _setGroupSelection(itemsCompoundPath, state, fullySelected); } else { if (item.data && item.data.noSelect) { return; } - setGroupSelection(item, state, fullySelected); + _setGroupSelection(item, state, fullySelected); } // @todo: Update toolbar state on change @@ -165,21 +165,19 @@ const getSelectedLeafItems = function () { return items; }; -const deleteItemSelection = function (items) { +const _deleteItemSelection = function (items, onUpdateSvg) { for (let i = 0; i < items.length; i++) { items[i].remove(); } // @todo: Update toolbar state on change - paper.project.view.update(); - // @todo add back undo - // pg.undo.snapshot('deleteItemSelection'); + if (items.length > 0) { + paper.project.view.update(); + onUpdateSvg(); + } }; -const removeSelectedSegments = function (items) { - // @todo add back undo - // pg.undo.snapshot('removeSelectedSegments'); - +const _removeSelectedSegments = function (items, onUpdateSvg) { const segmentsToRemove = []; for (let i = 0; i < items.length; i++) { @@ -198,161 +196,37 @@ const removeSelectedSegments = function (items) { seg.remove(); removedSegments = true; } + if (removedSegments) { + paper.project.view.update(); + onUpdateSvg(); + } return removedSegments; }; -const deleteSelection = function (mode) { +const deleteSelection = function (mode, onUpdateSvg) { if (mode === Modes.RESHAPE) { const selectedItems = getSelectedLeafItems(); // If there are points selected remove them. If not delete the item selected. - if (!removeSelectedSegments(selectedItems)) { - deleteItemSelection(selectedItems); + if (!_removeSelectedSegments(selectedItems, onUpdateSvg)) { + _deleteItemSelection(selectedItems, onUpdateSvg); } } else { const selectedItems = getSelectedRootItems(); - deleteItemSelection(selectedItems); + _deleteItemSelection(selectedItems, onUpdateSvg); } }; -const splitPathRetainSelection = function (path, index, deselectSplitSegments) { - const selectedPoints = []; - - // collect points of selected segments, so we can reselect them - // once the path is split. - for (let i = 0; i < path.segments.length; i++) { - const seg = path.segments[i]; - if (seg.selected) { - if (deselectSplitSegments && i === index) { - continue; - } - selectedPoints.push(seg.point); - } - } - - const newPath = path.split(index, 0); - if (!newPath) return; - - // reselect all of the newPaths segments that are in the exact same location - // as the ones that are stored in selectedPoints - for (let i = 0; i < newPath.segments.length; i++) { - const seg = newPath.segments[i]; - for (let j = 0; j < selectedPoints.length; j++) { - const point = selectedPoints[j]; - if (point.x === seg.point.x && point.y === seg.point.y) { - seg.selected = true; - } - } - } - - // only do this if path and newPath are different - // (split at more than one point) - if (path !== newPath) { - for (let i = 0; i < path.segments.length; i++) { - const seg = path.segments[i]; - for (let j = 0; j < selectedPoints.length; j++) { - const point = selectedPoints[j]; - if (point.x === seg.point.x && point.y === seg.point.y) { - seg.selected = true; - } - } - } - } -}; - -const splitPathAtSelectedSegments = function () { - const items = getSelectedRootItems(); - for (let i = 0; i < items.length; i++) { - const item = items[i]; - const segments = item.segments; - for (let j = 0; j < segments.length; j++) { - const segment = segments[j]; - if (segment.selected) { - if (item.closed || - (segment.next && - !segment.next.selected && - segment.previous && - !segment.previous.selected)) { - splitPathRetainSelection(item, j, true); - splitPathAtSelectedSegments(); - return; - } - } - } - } -}; - -const deleteSegments = function (item) { - if (item.children) { - for (let i = 0; i < item.children.length; i++) { - const child = item.children[i]; - deleteSegments(child); - } - } else { - const segments = item.segments; - for (let j = 0; j < segments.length; j++) { - const segment = segments[j]; - if (segment.selected) { - if (item.closed || - (segment.next && - !segment.next.selected && - segment.previous && - !segment.previous.selected)) { - - splitPathRetainSelection(item, j); - deleteSelection(); - return; - - } else if (!item.closed) { - segment.remove(); - j--; // decrease counter if we removed one from the loop - } - - } - } - } - // remove items with no segments left - if (item.segments.length <= 0) { - item.remove(); - } -}; - -const deleteSegmentSelection = function (items) { - for (let i = 0; i < items.length; i++) { - deleteSegments(items[i]); - } - - // @todo: Update toolbar state on change - paper.project.view.update(); - // @todo add back undo - // pg.undo.snapshot('deleteSegmentSelection'); -}; - -const cloneSelection = function (recursive) { +const cloneSelection = function (recursive, onUpdateSvg) { const selectedItems = recursive ? getSelectedLeafItems() : getSelectedRootItems(); for (let i = 0; i < selectedItems.length; i++) { const item = selectedItems[i]; item.clone(); item.selected = false; } - // @todo add back undo - // pg.undo.snapshot('cloneSelection'); + onUpdateSvg(); }; -// Only returns paths, no compound paths, groups or any other stuff -const getSelectedPaths = function () { - const allPaths = getSelectedRootItems(); - const paths = []; - - for (let i = 0; i < allPaths.length; i++) { - const path = allPaths[i]; - if (path.className === 'Path') { - paths.push(path); - } - } - return paths; -}; - -const checkBoundsItem = function (selectionRect, item, event) { +const _checkBoundsItem = function (selectionRect, item, event) { const itemBounds = new paper.Path([ item.localToGlobal(item.internalBounds.topLeft), item.localToGlobal(item.internalBounds.topRight), @@ -441,7 +315,7 @@ const _handleRectangularSelectionItems = function (item, event, rect, mode, root // @todo: Update toolbar state on change } else if (isBoundsItem(item)) { - if (checkBoundsItem(rect, item, event)) { + if (_checkBoundsItem(rect, item, event)) { return false; } } @@ -524,16 +398,10 @@ export { selectAllSegments, clearSelection, deleteSelection, - deleteItemSelection, - deleteSegmentSelection, - splitPathAtSelectedSegments, cloneSelection, setItemSelection, - setGroupSelection, getSelectedLeafItems, - getSelectedPaths, getSelectedRootItems, - removeSelectedSegments, processRectangularSelection, selectRootItem, shouldShowIfSelection, diff --git a/src/helper/style-path.js b/src/helper/style-path.js index 6e85b9f6..6910b573 100644 --- a/src/helper/style-path.js +++ b/src/helper/style-path.js @@ -1,3 +1,4 @@ +import paper from 'paper'; import {getSelectedLeafItems} from './selection'; import {isPGTextItem, isPointTextItem} from './item'; import {isGroup} from './group'; @@ -7,39 +8,56 @@ const MIXED = 'scratch-paint/style-path/mixed'; /** * Called when setting fill color * @param {string} colorString New color, css format + * @param {!function} onUpdateSvg A callback to call when the image visibly changes */ -const applyFillColorToSelection = function (colorString) { +const applyFillColorToSelection = function (colorString, onUpdateSvg) { const items = getSelectedLeafItems(); + let changed = false; 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; + if ((path.fillColor === null && colorString) || + path.fillColor.toCSS() !== new paper.Color(colorString).toCSS()) { + changed = true; + path.fillColor = colorString; + } } } } else if (!child.data.isPGGlyphRect) { - child.fillColor = colorString; + if ((child.fillColor === null && colorString) || + child.fillColor.toCSS() !== new paper.Color(colorString).toCSS()) { + changed = true; + child.fillColor = colorString; + } } } } else { if (isPointTextItem(item) && !colorString) { colorString = 'rgba(0,0,0,0)'; } - item.fillColor = colorString; + if ((item.fillColor === null && colorString) || + item.fillColor.toCSS() !== new paper.Color(colorString).toCSS()) { + changed = true; + item.fillColor = colorString; + } } } - // @todo add back undo + if (changed) { + onUpdateSvg(); + } }; /** * Called when setting stroke color * @param {string} colorString New color, css format + * @param {!function} onUpdateSvg A callback to call when the image visibly changes */ -const applyStrokeColorToSelection = function (colorString) { +const applyStrokeColorToSelection = function (colorString, onUpdateSvg) { const items = getSelectedLeafItems(); - + let changed = false; for (const item of items) { if (isPGTextItem(item)) { if (item.children) { @@ -47,37 +65,53 @@ const applyStrokeColorToSelection = function (colorString) { if (child.children) { for (const path of child.children) { if (!path.data.isPGGlyphRect) { - path.strokeColor = colorString; + if ((path.strokeColor === null && colorString) || + path.strokeColor.toCSS() !== new paper.Color(colorString).toCSS()) { + changed = true; + path.strokeColor = colorString; + } } } } else if (!child.data.isPGGlyphRect) { - child.strokeColor = colorString; + if (child.strokeColor !== colorString) { + changed = true; + child.strokeColor = colorString; + } } } } else if (!item.data.isPGGlyphRect) { - item.strokeColor = colorString; + if ((item.strokeColor === null && colorString) || + item.strokeColor.toCSS() !== new paper.Color(colorString).toCSS()) { + changed = true; + item.strokeColor = colorString; + } } - } else { + } else if ((item.strokeColor === null && colorString) || + item.strokeColor.toCSS() !== new paper.Color(colorString).toCSS()) { + changed = true; item.strokeColor = colorString; } } - // @todo add back undo + if (changed) { + onUpdateSvg(); + } }; /** * Called when setting stroke width * @param {number} value New stroke width + * @param {!function} onUpdateSvg A callback to call when the image visibly changes */ -const applyStrokeWidthToSelection = function (value) { +const applyStrokeWidthToSelection = function (value, onUpdateSvg) { const items = getSelectedLeafItems(); for (const item of items) { if (isGroup(item)) { continue; - } else { + } else if (item.strokeWidth !== value) { item.strokeWidth = value; + onUpdateSvg(); } } - // @todo add back undo }; /** @@ -167,8 +201,11 @@ const getColorsFromSelection = function (selectedItems) { const stylePath = function (path, options) { if (options.isEraser) { path.fillColor = 'white'; - } else { + } else if (options.fillColor) { path.fillColor = options.fillColor; + } else { + // Make sure something visible is drawn + path.fillColor = 'black'; } }; @@ -177,8 +214,11 @@ const styleCursorPreview = function (path, options) { path.fillColor = 'white'; path.strokeColor = 'cornflowerblue'; path.strokeWidth = 1; - } else { + } else if (options.fillColor) { path.fillColor = options.fillColor; + } else { + // Make sure something visible is drawn + path.fillColor = 'black'; } }; diff --git a/src/helper/undo.js b/src/helper/undo.js new file mode 100644 index 00000000..8e0e326e --- /dev/null +++ b/src/helper/undo.js @@ -0,0 +1,49 @@ +// undo functionality +// modifed from https://github.com/memononen/stylii +import paper from 'paper'; + +const performSnapshot = function (dispatchPerformSnapshot) { + dispatchPerformSnapshot({ + json: paper.project.exportJSON({asString: false}) + }); + + // @todo enable/disable buttons + // updateButtonVisibility(); +}; + +const _restore = function (entry, onUpdateSvg) { + for (const layer of paper.project.layers) { + layer.removeChildren(); + } + paper.project.clear(); + paper.project.importJSON(entry.json); + paper.view.update(); + onUpdateSvg(true /* skipSnapshot */); +}; + +const performUndo = function (undoState, dispatchPerformUndo, onUpdateSvg) { + if (undoState.pointer > 0) { + _restore(undoState.stack[undoState.pointer - 1], onUpdateSvg); + dispatchPerformUndo(); + + // @todo enable/disable buttons + // updateButtonVisibility(); + } +}; + + +const performRedo = function (undoState, dispatchPerformRedo, onUpdateSvg) { + if (undoState.pointer >= 0 && undoState.pointer < undoState.stack.length - 1) { + _restore(undoState.stack[undoState.pointer + 1], onUpdateSvg); + dispatchPerformRedo(); + + // @todo enable/disable buttons + // updateButtonVisibility(); + } +}; + +export { + performSnapshot, + performUndo, + performRedo +}; diff --git a/src/reducers/scratch-paint-reducer.js b/src/reducers/scratch-paint-reducer.js index 2ac4a2ee..12fb6d7f 100644 --- a/src/reducers/scratch-paint-reducer.js +++ b/src/reducers/scratch-paint-reducer.js @@ -5,6 +5,7 @@ import eraserModeReducer from './eraser-mode'; import colorReducer from './color'; import hoverReducer from './hover'; import selectedItemReducer from './selected-items'; +import undoReducer from './undo'; export default combineReducers({ mode: modeReducer, @@ -12,5 +13,6 @@ export default combineReducers({ eraserMode: eraserModeReducer, color: colorReducer, hoveredItemId: hoverReducer, - selectedItems: selectedItemReducer + selectedItems: selectedItemReducer, + undo: undoReducer }); diff --git a/src/reducers/undo.js b/src/reducers/undo.js new file mode 100644 index 00000000..2a669383 --- /dev/null +++ b/src/reducers/undo.js @@ -0,0 +1,79 @@ +import log from '../log/log'; + +const UNDO = 'scratch-paint/undo/UNDO'; +const REDO = 'scratch-paint/undo/REDO'; +const SNAPSHOT = 'scratch-paint/undo/SNAPSHOT'; +const CLEAR = 'scratch-paint/undo/CLEAR'; +const initialState = { + stack: [], + pointer: -1 +}; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case UNDO: + if (state.pointer <= 0) { + log.warn(`Can't undo, undo stack is empty`); + return state; + } + return { + stack: state.stack, + pointer: state.pointer - 1 + }; + case REDO: + if (state.pointer <= -1 || state.pointer === state.stack.length - 1) { + log.warn(`Can't redo, redo stack is empty`); + return state; + } + return { + stack: state.stack, + pointer: state.pointer + 1 + }; + case SNAPSHOT: + if (!action.snapshot) { + log.warn(`Couldn't create undo snapshot, no data provided`); + return state; + } + return { + // Performing an action clears the redo stack + stack: state.stack.slice(0, state.pointer + 1).concat(action.snapshot), + pointer: state.pointer + 1 + }; + case CLEAR: + return initialState; + default: + return state; + } +}; + +// Action creators ================================== +const undoSnapshot = function (snapshot) { + return { + type: SNAPSHOT, + snapshot: snapshot + }; +}; +const undo = function () { + return { + type: UNDO + }; +}; +const redo = function () { + return { + type: REDO + }; +}; +const clearUndoState = function () { + return { + type: CLEAR + }; +}; + +export { + reducer as default, + undo, + redo, + undoSnapshot, + clearUndoState +}; diff --git a/test/unit/undo-reducer.test.js b/test/unit/undo-reducer.test.js new file mode 100644 index 00000000..6201d9e3 --- /dev/null +++ b/test/unit/undo-reducer.test.js @@ -0,0 +1,141 @@ +/* eslint-env jest */ +import undoReducer from '../../src/reducers/undo'; +import {undoSnapshot, undo, redo, clearUndoState} from '../../src/reducers/undo'; + +test('initialState', () => { + let defaultState; + + expect(undoReducer(defaultState /* state */, {type: 'anything'} /* action */)).toBeDefined(); + expect(undoReducer(defaultState /* state */, {type: 'anything'} /* action */).pointer).toEqual(-1); + expect(undoReducer(defaultState /* state */, {type: 'anything'} /* action */).stack).toHaveLength(0); +}); + +test('snapshot', () => { + let defaultState; + const state1 = {state: 1}; + const state2 = {state: 2}; + + let reduxState = undoReducer(defaultState /* state */, undoSnapshot([state1]) /* action */); + expect(reduxState.pointer).toEqual(0); + expect(reduxState.stack).toHaveLength(1); + expect(reduxState.stack[0]).toEqual(state1); + + reduxState = undoReducer(reduxState /* state */, undoSnapshot([state2]) /* action */); + expect(reduxState.pointer).toEqual(1); + expect(reduxState.stack).toHaveLength(2); + expect(reduxState.stack[0]).toEqual(state1); + expect(reduxState.stack[1]).toEqual(state2); +}); + +test('invalidSnapshot', () => { + let defaultState; + const state1 = {state: 1}; + + const reduxState = undoReducer(defaultState /* state */, undoSnapshot([state1]) /* action */); + const newReduxState = undoReducer(reduxState /* state */, undoSnapshot() /* action */); // No snapshot provided + expect(reduxState).toEqual(newReduxState); +}); + +test('clearUndoState', () => { + let defaultState; + const state1 = {state: 1}; + const state2 = {state: 2}; + + // Push 2 states then clear + const reduxState = undoReducer(defaultState /* state */, undoSnapshot([state1]) /* action */); + undoReducer(reduxState /* state */, undoSnapshot([state2]) /* action */); + const newReduxState = undoReducer(reduxState /* state */, clearUndoState() /* action */); + + expect(newReduxState.pointer).toEqual(-1); + expect(newReduxState.stack).toHaveLength(0); +}); + +test('cantUndo', () => { + let defaultState; + const state1 = {state: 1}; + + // Undo when there's no undo stack + let reduxState = undoReducer(defaultState /* state */, undo() /* action */); + + expect(reduxState.pointer).toEqual(-1); + expect(reduxState.stack).toHaveLength(0); + + // Undo when there's only one state + reduxState = undoReducer(reduxState /* state */, undoSnapshot([state1]) /* action */); + reduxState = undoReducer(reduxState /* state */, undo() /* action */); + + expect(reduxState.pointer).toEqual(0); + expect(reduxState.stack).toHaveLength(1); +}); + +test('cantRedo', () => { + let defaultState; + const state1 = {state: 1}; + + let reduxState = undoReducer(defaultState /* state */, undoSnapshot([state1]) /* action */); + + // Redo when there's no redo stack + reduxState = undoReducer(reduxState /* state */, redo() /* action */); + + expect(reduxState.pointer).toEqual(0); + expect(reduxState.stack).toHaveLength(1); +}); + +test('undo', () => { + let defaultState; + const state1 = {state: 1}; + const state2 = {state: 2}; + + // Push 2 states then undo one + let reduxState = undoReducer(defaultState /* state */, undoSnapshot([state1]) /* action */); + reduxState = undoReducer(reduxState /* state */, undoSnapshot([state2]) /* action */); + reduxState = undoReducer(reduxState /* state */, undo() /* action */); + + expect(reduxState.pointer).toEqual(0); + expect(reduxState.stack).toHaveLength(2); + expect(reduxState.stack[0]).toEqual(state1); + expect(reduxState.stack[1]).toEqual(state2); +}); + +test('redo', () => { + let defaultState; + const state1 = {state: 1}; + const state2 = {state: 2}; + + // Push 2 states then undo one + let reduxState = undoReducer(defaultState /* state */, undoSnapshot([state1]) /* action */); + reduxState = undoReducer(reduxState /* state */, undoSnapshot([state2]) /* action */); + let newReduxState = undoReducer(reduxState /* state */, undo() /* action */); + + // Now redo and check equality with previous state + newReduxState = undoReducer(newReduxState /* state */, redo() /* action */); + expect(newReduxState.pointer).toEqual(reduxState.pointer); + expect(newReduxState.stack).toHaveLength(reduxState.stack.length); + expect(newReduxState.stack[0]).toEqual(reduxState.stack[0]); + expect(reduxState.stack[1]).toEqual(reduxState.stack[1]); +}); + +test('undoSnapshotCantRedo', () => { + let defaultState; + const state1 = {state: 1}; + const state2 = {state: 2}; + const state3 = {state: 3}; + + // Push 2 states then undo + let reduxState = undoReducer(defaultState /* state */, undoSnapshot([state1]) /* action */); + reduxState = undoReducer(reduxState /* state */, undoSnapshot([state2]) /* action */); + reduxState = undoReducer(reduxState /* state */, undo() /* action */); + + expect(reduxState.pointer).toEqual(0); + expect(reduxState.stack).toHaveLength(2); + + // Snapshot + reduxState = undoReducer(reduxState /* state */, undoSnapshot([state3]) /* action */); + // Redo should do nothing + const newReduxState = undoReducer(reduxState /* state */, redo() /* action */); + + expect(newReduxState.pointer).toEqual(reduxState.pointer); + expect(newReduxState.stack).toHaveLength(reduxState.stack.length); + expect(newReduxState.stack[0]).toEqual(reduxState.stack[0]); + expect(newReduxState.stack[1]).toEqual(state3); +});