diff --git a/src/components/paint-editor/icons/bitmap.svg b/src/components/paint-editor/icons/bitmap.svg index 8b07f54e..47832b0c 100644 --- a/src/components/paint-editor/icons/bitmap.svg +++ b/src/components/paint-editor/icons/bitmap.svg @@ -5,7 +5,7 @@ Created with Sketch. - + diff --git a/src/components/paint-editor/paint-editor.css b/src/components/paint-editor/paint-editor.css index 38ca5063..f5500253 100644 --- a/src/components/paint-editor/paint-editor.css +++ b/src/components/paint-editor/paint-editor.css @@ -150,6 +150,10 @@ $border-radius: 0.25rem; justify-content: space-between; } +.hidden { + display: none; +} + .zoom-controls { display: flex; flex-direction: row-reverse; @@ -179,13 +183,19 @@ $border-radius: 0.25rem; .bitmap-button { display: flex; border-radius: 5px; - background-color: hsla(0, 0%, 0%, .25); + background-color: $motion-primary; padding: calc(2 * $grid-unit); line-height: 1.5rem; font-size: calc(3 * $grid-unit); font-weight: bold; + color: white; justify-content: center; - opacity: .5; + opacity: .75; +} + +.bitmap-button:active { + background-color: $motion-primary; + opacity: 1; } .bitmap-button-icon { diff --git a/src/components/paint-editor/paint-editor.jsx b/src/components/paint-editor/paint-editor.jsx index 72297011..771bf79e 100644 --- a/src/components/paint-editor/paint-editor.jsx +++ b/src/components/paint-editor/paint-editor.jsx @@ -15,7 +15,6 @@ import Button from '../button/button.jsx'; import ButtonGroup from '../button-group/button-group.jsx'; import BrushMode from '../../containers/brush-mode.jsx'; import BufferedInputHOC from '../forms/buffered-input-hoc.jsx'; -import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx'; import Dropdown from '../dropdown/dropdown.jsx'; import EraserMode from '../../containers/eraser-mode.jsx'; import FillColorIndicatorComponent from '../../containers/fill-color-indicator.jsx'; @@ -35,6 +34,8 @@ import StrokeColorIndicatorComponent from '../../containers/stroke-color-indicat import StrokeWidthIndicatorComponent from '../../containers/stroke-width-indicator.jsx'; import TextMode from '../../containers/text-mode.jsx'; +import Formats from '../../lib/format'; +import {isVector} from '../../lib/format'; import layout from '../../lib/layout-constants'; import styles from './paint-editor.css'; @@ -107,6 +108,11 @@ const messages = defineMessages({ defaultMessage: 'Convert to Bitmap', description: 'Label for button that converts the paint editor to bitmap mode', id: 'paint.paintEditor.bitmap' + }, + vector: { + defaultMessage: 'Convert to Vector', + description: 'Label for button that converts the paint editor to vector mode', + id: 'paint.paintEditor.vector' } }); @@ -337,7 +343,7 @@ const PaintEditorComponent = props => {
{/* Modes */} {props.canvas !== null ? ( // eslint-disable-line no-negated-condition -
+
@@ -403,12 +409,11 @@ const PaintEditorComponent = props => { }
- -
+ {isVector(props.format) ? +
-
+ : + + } {/* Zoom controls */} @@ -469,6 +487,7 @@ PaintEditorComponent.propTypes = { canUndo: PropTypes.func.isRequired, canvas: PropTypes.instanceOf(Element), colorInfo: Loupe.propTypes.colorInfo, + format: PropTypes.oneOf(Object.keys(Formats)).isRequired, intl: intlShape, isEyeDropping: PropTypes.bool, name: PropTypes.string, @@ -478,6 +497,8 @@ PaintEditorComponent.propTypes = { onSendForward: PropTypes.func.isRequired, onSendToBack: PropTypes.func.isRequired, onSendToFront: PropTypes.func.isRequired, + onSwitchToBitmap: PropTypes.func.isRequired, + onSwitchToVector: PropTypes.func.isRequired, onUndo: PropTypes.func.isRequired, onUngroup: PropTypes.func.isRequired, onUpdateName: PropTypes.func.isRequired, diff --git a/src/containers/paint-editor.jsx b/src/containers/paint-editor.jsx index 11f9e267..61feaf99 100644 --- a/src/containers/paint-editor.jsx +++ b/src/containers/paint-editor.jsx @@ -1,16 +1,19 @@ import paper from '@scratch/paper'; import PropTypes from 'prop-types'; + import React from 'react'; import PaintEditorComponent from '../components/paint-editor/paint-editor.jsx'; import {changeMode} from '../reducers/modes'; +import {changeFormat} from '../reducers/format'; import {undo, redo, undoSnapshot} from '../reducers/undo'; import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; import {deactivateEyeDropper} from '../reducers/eye-dropper'; import {setTextEditTarget} from '../reducers/text-edit-target'; import {updateViewBounds} from '../reducers/view-bounds'; -import {hideGuideLayers, showGuideLayers} from '../helper/layer'; +import {getRaster, hideGuideLayers, showGuideLayers} from '../helper/layer'; +import {trim} from '../helper/bitmap'; import {performUndo, performRedo, performSnapshot, shouldShowUndo, shouldShowRedo} from '../helper/undo'; import {bringToFront, sendBackward, sendToBack, bringForward} from '../helper/order'; import {groupSelection, ungroupSelection} from '../helper/group'; @@ -19,6 +22,8 @@ import {resetZoom, zoomOnSelection} from '../helper/view'; import EyeDropperTool from '../helper/tools/eye-dropper'; import Modes from '../lib/modes'; +import Formats from '../lib/format'; +import {isBitmap} from '../lib/format'; import {connect} from 'react-redux'; import bindAll from 'lodash.bindall'; @@ -88,9 +93,20 @@ class PaintEditor extends React.Component { const oldCenter = paper.project.view.center.clone(); resetZoom(); - const guideLayers = hideGuideLayers(); - + let raster; + if (isBitmap(this.props.format)) { + // @todo export bitmap here + raster = trim(getRaster()); + if (raster.width === 0 || raster.height === 0) { + raster.remove(); + } else { + paper.project.activeLayer.addChild(raster); + } + } + + const guideLayers = hideGuideLayers(true /* includeRaster */); const bounds = paper.project.activeLayer.bounds; + this.props.onUpdateSvg( paper.project.exportSVG({ asString: true, @@ -101,9 +117,10 @@ class PaintEditor extends React.Component { paper.project.view.center.y - bounds.y); showGuideLayers(guideLayers); + if (raster) raster.remove(); if (!skipSnapshot) { - performSnapshot(this.props.undoSnapshot); + performSnapshot(this.props.undoSnapshot, this.props.format); } // Restore old zoom @@ -231,6 +248,7 @@ class PaintEditor extends React.Component { canUndo={this.canUndo} canvas={this.state.canvas} colorInfo={this.state.colorInfo} + format={this.props.format} isEyeDropping={this.props.isEyeDropping} name={this.props.name} rotationCenterX={this.props.rotationCenterX} @@ -246,6 +264,8 @@ class PaintEditor extends React.Component { onSendForward={this.handleSendForward} onSendToBack={this.handleSendToBack} onSendToFront={this.handleSendToFront} + onSwitchToBitmap={this.props.handleSwitchToBitmap} + onSwitchToVector={this.props.handleSwitchToVector} onUndo={this.handleUndo} onUngroup={this.handleUngroup} onUpdateName={this.props.onUpdateName} @@ -261,6 +281,9 @@ class PaintEditor extends React.Component { PaintEditor.propTypes = { changeColorToEyeDropper: PropTypes.func, clearSelectedItems: PropTypes.func.isRequired, + format: PropTypes.oneOf(Object.keys(Formats)).isRequired, + handleSwitchToBitmap: PropTypes.func.isRequired, + handleSwitchToVector: PropTypes.func.isRequired, isEyeDropping: PropTypes.bool, name: PropTypes.string, onDeactivateEyeDropper: PropTypes.func.isRequired, @@ -291,6 +314,7 @@ PaintEditor.propTypes = { const mapStateToProps = state => ({ changeColorToEyeDropper: state.scratchPaint.color.eyeDropper.callback, clipboardItems: state.scratchPaint.clipboard.items, + format: state.scratchPaint.format, isEyeDropping: state.scratchPaint.color.eyeDropper.active, pasteOffset: state.scratchPaint.clipboard.pasteOffset, previousTool: state.scratchPaint.color.eyeDropper.previousTool, @@ -323,6 +347,12 @@ const mapDispatchToProps = dispatch => ({ clearSelectedItems: () => { dispatch(clearSelectedItems()); }, + handleSwitchToBitmap: () => { + dispatch(changeFormat(Formats.BITMAP)); + }, + handleSwitchToVector: () => { + dispatch(changeFormat(Formats.VECTOR)); + }, removeTextEditTarget: () => { dispatch(setTextEditTarget()); }, @@ -333,11 +363,11 @@ const mapDispatchToProps = dispatch => ({ // set redux values to default for eye dropper reducer dispatch(deactivateEyeDropper()); }, - onUndo: () => { - dispatch(undo()); + onUndo: format => { + dispatch(undo(format)); }, - onRedo: () => { - dispatch(redo()); + onRedo: format => { + dispatch(redo(format)); }, undoSnapshot: snapshot => { dispatch(undoSnapshot(snapshot)); diff --git a/src/containers/paper-canvas.css b/src/containers/paper-canvas.css index 65a26ef0..aa352c6a 100644 --- a/src/containers/paper-canvas.css +++ b/src/containers/paper-canvas.css @@ -4,7 +4,4 @@ margin: auto; position: absolute; background-color: #fff; - /* Turn off anti-aliasing for the drawing canvas. Each time it's updated it switches - back and forth from aliased to unaliased and that looks bad */ - image-rendering: pixelated; } diff --git a/src/containers/paper-canvas.jsx b/src/containers/paper-canvas.jsx index bac7ed42..5655eb85 100644 --- a/src/containers/paper-canvas.jsx +++ b/src/containers/paper-canvas.jsx @@ -3,13 +3,15 @@ import PropTypes from 'prop-types'; import React from 'react'; import {connect} from 'react-redux'; import paper from '@scratch/paper'; +import Formats from '../lib/format'; import Modes from '../lib/modes'; import log from '../log/log'; +import {trim} from '../helper/bitmap'; import {performSnapshot} from '../helper/undo'; import {undoSnapshot, clearUndoState} from '../reducers/undo'; import {isGroup, ungroupItems} from '../helper/group'; -import {setupLayers} from '../helper/layer'; +import {clearRaster, getRaster, setupLayers} from '../helper/layer'; import {deleteSelection, getSelectedLeafItems} from '../helper/selection'; import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; import {pan, resetZoom, zoomOnFixedPoint} from '../helper/view'; @@ -17,6 +19,9 @@ import {ensureClockwise} from '../helper/math'; import {clearHoveredItem} from '../reducers/hover'; import {clearPasteOffset} from '../reducers/clipboard'; import {updateViewBounds} from '../reducers/view-bounds'; +import {changeFormat} from '../reducers/format'; + +import {isVector, isBitmap} from '../lib/format'; import styles from './paper-canvas.css'; @@ -24,15 +29,23 @@ class PaperCanvas extends React.Component { constructor (props) { super(props); bindAll(this, [ + 'convertToBitmap', + 'convertToVector', 'setCanvas', 'importSvg', 'handleKeyDown', - 'handleWheel' + 'handleWheel', + 'switchCostume' ]); } componentDidMount () { document.addEventListener('keydown', this.handleKeyDown); paper.setup(this.canvas); + + const context = this.canvas.getContext('2d'); + context.webkitImageSmoothingEnabled = false; + context.imageSmoothingEnabled = false; + // Don't show handles by default paper.settings.handleSize = 0; // Make layers. @@ -40,31 +53,16 @@ class PaperCanvas extends React.Component { if (this.props.svg) { this.importSvg(this.props.svg, this.props.rotationCenterX, this.props.rotationCenterY); } else { - performSnapshot(this.props.undoSnapshot); + performSnapshot(this.props.undoSnapshot, this.props.format); } } componentWillReceiveProps (newProps) { - if (this.props.svgId === newProps.svgId) return; - for (const layer of paper.project.layers) { - if (!layer.data.isBackgroundGuideLayer) { - layer.removeChildren(); - } - } - this.props.clearUndo(); - this.props.clearSelectedItems(); - this.props.clearHoveredItem(); - this.props.clearPasteOffset(); - if (newProps.svg) { - // Store the zoom/pan and restore it after importing a new SVG - const oldZoom = paper.project.view.zoom; - const oldCenter = paper.project.view.center.clone(); - resetZoom(); - this.props.updateViewBounds(paper.view.matrix); - this.importSvg(newProps.svg, newProps.rotationCenterX, newProps.rotationCenterY); - paper.project.view.zoom = oldZoom; - paper.project.view.center = oldCenter; - } else { - performSnapshot(this.props.undoSnapshot); + if (this.props.svgId !== newProps.svgId) { + this.switchCostume(newProps.svg, newProps.rotationCenterX, newProps.rotationCenterY); + } else if (isVector(this.props.format) && newProps.format === Formats.BITMAP) { + this.convertToBitmap(); + } else if (isBitmap(this.props.format) && newProps.format === Formats.VECTOR) { + this.convertToVector(); } } componentWillUnmount () { @@ -83,6 +81,53 @@ class PaperCanvas extends React.Component { } } } + convertToBitmap () { + this.props.clearSelectedItems(); + const raster = paper.project.activeLayer.rasterize(72, false /* insert */); + raster.onLoad = function () { + const subCanvas = raster.canvas; + getRaster().drawImage(subCanvas, raster.bounds.topLeft); + paper.project.activeLayer.removeChildren(); + this.props.onUpdateSvg(); + }.bind(this); + } + convertToVector () { + this.props.clearSelectedItems(); + const raster = trim(getRaster()); + if (raster.width === 0 || raster.height === 0) { + raster.remove(); + } else { + paper.project.activeLayer.addChild(raster); + } + clearRaster(); + this.props.onUpdateSvg(); + } + switchCostume (svg, rotationCenterX, rotationCenterY) { + for (const layer of paper.project.layers) { + if (layer.data.isRasterLayer) { + clearRaster(); + } else if (!layer.data.isBackgroundGuideLayer) { + layer.removeChildren(); + } + } + this.props.clearUndo(); + this.props.clearSelectedItems(); + this.props.clearHoveredItem(); + this.props.clearPasteOffset(); + if (svg) { + this.props.changeFormat(Formats.VECTOR_SKIP_CONVERT); + // Store the zoom/pan and restore it after importing a new SVG + const oldZoom = paper.project.view.zoom; + const oldCenter = paper.project.view.center.clone(); + resetZoom(); + this.props.updateViewBounds(paper.view.matrix); + this.importSvg(svg, rotationCenterX, rotationCenterY); + paper.project.view.zoom = oldZoom; + paper.project.view.center = oldCenter; + } else { + performSnapshot(this.props.undoSnapshot, this.props.format); + } + } importSvg (svg, rotationCenterX, rotationCenterY) { const paperCanvas = this; // Pre-process SVG to prevent parsing errors (discussion from #213) @@ -115,7 +160,7 @@ class PaperCanvas extends React.Component { if (!item) { log.error('SVG import failed:'); log.info(svg); - performSnapshot(paperCanvas.props.undoSnapshot); + performSnapshot(paperCanvas.props.undoSnapshot, paperCanvas.props.format); return; } const itemWidth = item.bounds.width; @@ -157,7 +202,7 @@ class PaperCanvas extends React.Component { ungroupItems([item]); } - performSnapshot(paperCanvas.props.undoSnapshot); + performSnapshot(paperCanvas.props.undoSnapshot, paperCanvas.props.format); } }); } @@ -209,10 +254,12 @@ class PaperCanvas extends React.Component { PaperCanvas.propTypes = { canvasRef: PropTypes.func, + changeFormat: PropTypes.func.isRequired, clearHoveredItem: PropTypes.func.isRequired, clearPasteOffset: PropTypes.func.isRequired, clearSelectedItems: PropTypes.func.isRequired, clearUndo: PropTypes.func.isRequired, + format: PropTypes.oneOf(Object.keys(Formats)).isRequired, mode: PropTypes.oneOf(Object.keys(Modes)), onUpdateSvg: PropTypes.func.isRequired, rotationCenterX: PropTypes.number, @@ -224,7 +271,8 @@ PaperCanvas.propTypes = { updateViewBounds: PropTypes.func.isRequired }; const mapStateToProps = state => ({ - mode: state.scratchPaint.mode + mode: state.scratchPaint.mode, + format: state.scratchPaint.format }); const mapDispatchToProps = dispatch => ({ undoSnapshot: snapshot => { @@ -245,6 +293,9 @@ const mapDispatchToProps = dispatch => ({ clearPasteOffset: () => { dispatch(clearPasteOffset()); }, + changeFormat: format => { + dispatch(changeFormat(format)); + }, updateViewBounds: matrix => { dispatch(updateViewBounds(matrix)); } diff --git a/src/helper/bitmap.js b/src/helper/bitmap.js new file mode 100644 index 00000000..5c1db739 --- /dev/null +++ b/src/helper/bitmap.js @@ -0,0 +1,37 @@ +import paper from '@scratch/paper'; + +const rowBlank_ = function (imageData, width, y) { + for (let x = 0; x < width; ++x) { + if (imageData.data[(y * width << 2) + (x << 2) + 3] !== 0) return false; + } + return true; +}; + +const columnBlank_ = function (imageData, width, x, top, bottom) { + for (let y = top; y < bottom; ++y) { + if (imageData.data[(y * width << 2) + (x << 2) + 3] !== 0) return false; + } + return true; +}; + +// Adapted from Tim Down's https://gist.github.com/timdown/021d9c8f2aabc7092df564996f5afbbf +// Trims transparent pixels from edges. +const trim = function (raster) { + const width = raster.width; + const imageData = raster.getImageData(raster.bounds); + let top = 0; + let bottom = imageData.height; + let left = 0; + let right = imageData.width; + + while (top < bottom && rowBlank_(imageData, width, top)) ++top; + while (bottom - 1 > top && rowBlank_(imageData, width, bottom - 1)) --bottom; + while (left < right && columnBlank_(imageData, width, left, top, bottom)) ++left; + while (right - 1 > left && columnBlank_(imageData, width, right - 1, top, bottom)) --right; + + return raster.getSubRaster(new paper.Rectangle(left, top, right - left, bottom - top)); +}; + +export { + trim +}; diff --git a/src/helper/layer.js b/src/helper/layer.js index 677dbae0..837d3747 100644 --- a/src/helper/layer.js +++ b/src/helper/layer.js @@ -1,4 +1,5 @@ import paper from '@scratch/paper'; +import rasterSrc from './transparent.png'; import log from '../log/log'; const _getLayer = function (layerString) { @@ -7,33 +8,66 @@ const _getLayer = function (layerString) { return layer; } } - log.error(`Didn't find layer ${layerString}`); }; const _getPaintingLayer = function () { return _getLayer('isPaintingLayer'); }; +const clearRaster = function () { + const layer = _getLayer('isRasterLayer'); + layer.removeChildren(); + + // Generate blank raster + const raster = new paper.Raster(rasterSrc); + raster.parent = layer; + raster.guide = true; + raster.locked = true; + raster.position = paper.view.center; +}; + +const getRaster = function () { + return _getLayer('isRasterLayer').children[0]; +}; + const _getBackgroundGuideLayer = function () { return _getLayer('isBackgroundGuideLayer'); }; +const _makeGuideLayer = function () { + const guideLayer = new paper.Layer(); + guideLayer.data.isGuideLayer = true; + return guideLayer; +}; + const getGuideLayer = function () { - return _getLayer('isGuideLayer'); + let layer = _getLayer('isGuideLayer'); + if (!layer) { + layer = _makeGuideLayer(); + _getPaintingLayer().activate(); + } + return layer; }; /** * Removes the guide layers, e.g. for purposes of exporting the image. Must call showGuideLayers to re-add them. + * @param {boolean} includeRaster true if the raster layer should also be hidden * @return {object} an object of the removed layers, which should be passed to showGuideLayers to re-add them. */ -const hideGuideLayers = function () { +const hideGuideLayers = function (includeRaster) { const backgroundGuideLayer = _getBackgroundGuideLayer(); const guideLayer = getGuideLayer(); guideLayer.remove(); backgroundGuideLayer.remove(); + let rasterLayer; + if (includeRaster) { + rasterLayer = _getLayer('isRasterLayer'); + rasterLayer.remove(); + } return { guideLayer: guideLayer, - backgroundGuideLayer: backgroundGuideLayer + backgroundGuideLayer: backgroundGuideLayer, + rasterLayer: rasterLayer }; }; @@ -45,6 +79,11 @@ const hideGuideLayers = function () { const showGuideLayers = function (guideLayers) { const backgroundGuideLayer = guideLayers.backgroundGuideLayer; const guideLayer = guideLayers.guideLayer; + const rasterLayer = guideLayers.rasterLayer; + if (rasterLayer && !rasterLayer.index) { + paper.project.addLayer(rasterLayer); + rasterLayer.sendToBack(); + } if (!backgroundGuideLayer.index) { paper.project.addLayer(backgroundGuideLayer); backgroundGuideLayer.sendToBack(); @@ -59,18 +98,19 @@ const showGuideLayers = function (guideLayers) { } }; -const _makeGuideLayer = function () { - const guideLayer = new paper.Layer(); - guideLayer.data.isGuideLayer = true; - return guideLayer; -}; - const _makePaintingLayer = function () { const paintingLayer = new paper.Layer(); paintingLayer.data.isPaintingLayer = true; return paintingLayer; }; +const _makeRasterLayer = function () { + const rasterLayer = new paper.Layer(); + rasterLayer.data.isRasterLayer = true; + clearRaster(); + return rasterLayer; +}; + const _makeBackgroundPaper = function (width, height, color) { // creates a checkerboard path of width * height squares in color on white let x = 0; @@ -140,6 +180,7 @@ const _makeBackgroundGuideLayer = function () { const setupLayers = function () { const backgroundGuideLayer = _makeBackgroundGuideLayer(); + _makeRasterLayer(); const paintLayer = _makePaintingLayer(); const guideLayer = _makeGuideLayer(); backgroundGuideLayer.sendToBack(); @@ -151,5 +192,7 @@ export { hideGuideLayers, showGuideLayers, getGuideLayer, + clearRaster, + getRaster, setupLayers }; diff --git a/src/helper/transparent.png b/src/helper/transparent.png new file mode 100644 index 00000000..ebfbd78d Binary files /dev/null and b/src/helper/transparent.png differ diff --git a/src/helper/undo.js b/src/helper/undo.js index 3302e833..3f9592d2 100644 --- a/src/helper/undo.js +++ b/src/helper/undo.js @@ -1,18 +1,28 @@ // undo functionality // modifed from https://github.com/memononen/stylii import paper from '@scratch/paper'; -import {hideGuideLayers, showGuideLayers} from '../helper/layer'; +import {hideGuideLayers, showGuideLayers, getRaster} from '../helper/layer'; +import Formats from '../lib/format'; +import {isVector, isBitmap} from '../lib/format'; +import log from '../log/log'; -const performSnapshot = function (dispatchPerformSnapshot) { +/** + * Take an undo snapshot + * @param {function} dispatchPerformSnapshot Callback to dispatch a state update + * @param {Formats} format Either Formats.BITMAP or Formats.VECTOR + */ +const performSnapshot = function (dispatchPerformSnapshot, format) { const guideLayers = hideGuideLayers(); dispatchPerformSnapshot({ - json: paper.project.exportJSON({asString: false}) + json: paper.project.exportJSON({asString: false}), + paintEditorFormat: format }); showGuideLayers(guideLayers); }; const _restore = function (entry, setSelectedItems, onUpdateSvg) { - for (const layer of paper.project.layers) { + for (let i = paper.project.layers.length - 1; i >= 0; i--) { + const layer = paper.project.layers[i]; if (!layer.data.isBackgroundGuideLayer) { layer.removeChildren(); layer.remove(); @@ -21,21 +31,38 @@ const _restore = function (entry, setSelectedItems, onUpdateSvg) { paper.project.importJSON(entry.json); setSelectedItems(); - onUpdateSvg(true /* skipSnapshot */); + getRaster().onLoad = function () { + onUpdateSvg(true /* skipSnapshot */); + }; + if (getRaster().loaded) { + getRaster().onLoad(); + } }; const performUndo = function (undoState, dispatchPerformUndo, setSelectedItems, onUpdateSvg) { if (undoState.pointer > 0) { - _restore(undoState.stack[undoState.pointer - 1], setSelectedItems, onUpdateSvg); - dispatchPerformUndo(); + const state = undoState.stack[undoState.pointer - 1]; + _restore(state, setSelectedItems, onUpdateSvg); + const format = isVector(state.paintEditorFormat) ? Formats.VECTOR_SKIP_CONVERT : + isBitmap(state.paintEditorFormat) ? Formats.BITMAP_SKIP_CONVERT : null; + if (!format) { + log.error(`Invalid format: ${state.paintEditorFormat}`); + } + dispatchPerformUndo(format); } }; const performRedo = function (undoState, dispatchPerformRedo, setSelectedItems, onUpdateSvg) { if (undoState.pointer >= 0 && undoState.pointer < undoState.stack.length - 1) { - _restore(undoState.stack[undoState.pointer + 1], setSelectedItems, onUpdateSvg); - dispatchPerformRedo(); + const state = undoState.stack[undoState.pointer + 1]; + _restore(state, setSelectedItems, onUpdateSvg); + const format = isVector(state.paintEditorFormat) ? Formats.VECTOR_SKIP_CONVERT : + isBitmap(state.paintEditorFormat) ? Formats.BITMAP_SKIP_CONVERT : null; + if (!format) { + log.error(`Invalid format: ${state.paintEditorFormat}`); + } + dispatchPerformRedo(format); } }; diff --git a/src/lib/format.js b/src/lib/format.js new file mode 100644 index 00000000..abad144f --- /dev/null +++ b/src/lib/format.js @@ -0,0 +1,23 @@ +import keyMirror from 'keymirror'; + +const Formats = keyMirror({ + BITMAP: null, + VECTOR: null, + // Format changes which should not trigger conversions, for instance undo + BITMAP_SKIP_CONVERT: null, + VECTOR_SKIP_CONVERT: null +}); + +const isVector = function (format) { + return format === Formats.VECTOR || format === Formats.VECTOR_SKIP_CONVERT; +}; + +const isBitmap = function (format) { + return format === Formats.BITMAP || format === Formats.BITMAP_SKIP_CONVERT; +}; + +export { + Formats as default, + isVector, + isBitmap +}; diff --git a/src/reducers/format.js b/src/reducers/format.js new file mode 100644 index 00000000..9243e3fc --- /dev/null +++ b/src/reducers/format.js @@ -0,0 +1,37 @@ +import Formats from '../lib/format'; +import log from '../log/log'; +import {UNDO, REDO} from './undo'; + +const CHANGE_FORMAT = 'scratch-paint/formats/CHANGE_FORMAT'; +const initialState = Formats.VECTOR; + +const reducer = function (state, action) { + if (typeof state === 'undefined') state = initialState; + switch (action.type) { + case UNDO: + /* falls through */ + case REDO: + /* falls through */ + case CHANGE_FORMAT: + if (action.format in Formats) { + return action.format; + } + log.warn(`Format does not exist: ${action.format}`); + /* falls through */ + default: + return state; + } +}; + +// Action creators ================================== +const changeFormat = function (format) { + return { + type: CHANGE_FORMAT, + format: format + }; +}; + +export { + reducer as default, + changeFormat +}; diff --git a/src/reducers/scratch-paint-reducer.js b/src/reducers/scratch-paint-reducer.js index 258dea9e..224dcad7 100644 --- a/src/reducers/scratch-paint-reducer.js +++ b/src/reducers/scratch-paint-reducer.js @@ -4,6 +4,7 @@ import brushModeReducer from './brush-mode'; import eraserModeReducer from './eraser-mode'; import colorReducer from './color'; import clipboardReducer from './clipboard'; +import formatReducer from './format'; import hoverReducer from './hover'; import modalsReducer from './modals'; import selectedItemReducer from './selected-items'; @@ -17,6 +18,7 @@ export default combineReducers({ color: colorReducer, clipboard: clipboardReducer, eraserMode: eraserModeReducer, + format: formatReducer, hoveredItemId: hoverReducer, modals: modalsReducer, selectedItems: selectedItemReducer, diff --git a/src/reducers/undo.js b/src/reducers/undo.js index ce16c108..e372ffa2 100644 --- a/src/reducers/undo.js +++ b/src/reducers/undo.js @@ -63,14 +63,24 @@ const undoSnapshot = function (snapshot) { snapshot: snapshot }; }; -const undo = function () { +/** + * @param {Format} format Either VECTOR_SKIP_CONVERT or BITMAP_SKIP_CONVERT + * @return {Action} undo action + */ +const undo = function (format) { return { - type: UNDO + type: UNDO, + format: format }; }; -const redo = function () { +/** + * @param {Format} format Either VECTOR_SKIP_CONVERT or BITMAP_SKIP_CONVERT + * @return {Action} undo action + */ +const redo = function (format) { return { - type: REDO + type: REDO, + format: format }; }; const clearUndoState = function () { @@ -85,5 +95,7 @@ export { redo, undoSnapshot, clearUndoState, - MAX_STACK_SIZE + MAX_STACK_SIZE, + UNDO, + REDO }; diff --git a/test/unit/format-reducer.test.js b/test/unit/format-reducer.test.js new file mode 100644 index 00000000..1941aed3 --- /dev/null +++ b/test/unit/format-reducer.test.js @@ -0,0 +1,35 @@ +/* eslint-env jest */ +import Formats from '../../src/lib/format'; +import reducer from '../../src/reducers/format'; +import {changeFormat} from '../../src/reducers/format'; +import {undo, redo} from '../../src/reducers/undo'; + +test('initialState', () => { + let defaultState; + expect(reducer(defaultState /* state */, {type: 'anything'} /* action */) in Formats).toBeTruthy(); +}); + +test('changeFormat', () => { + let defaultState; + expect(reducer(defaultState /* state */, changeFormat(Formats.BITMAP) /* action */)).toBe(Formats.BITMAP); + expect(reducer(Formats.BITMAP /* state */, changeFormat(Formats.BITMAP) /* action */)) + .toBe(Formats.BITMAP); + expect(reducer(Formats.BITMAP /* state */, changeFormat(Formats.VECTOR) /* action */)) + .toBe(Formats.VECTOR); +}); + +test('undoRedoChangeFormat', () => { + let defaultState; + let reduxState = reducer(defaultState /* state */, changeFormat(Formats.BITMAP) /* action */); + expect(reduxState).toBe(Formats.BITMAP); + reduxState = reducer(reduxState /* state */, undo(Formats.BITMAP_SKIP_CONVERT) /* action */); + expect(reduxState).toBe(Formats.BITMAP_SKIP_CONVERT); + reduxState = reducer(reduxState /* state */, redo(Formats.VECTOR_SKIP_CONVERT) /* action */); + expect(reduxState).toBe(Formats.VECTOR_SKIP_CONVERT); +}); + +test('invalidChangeMode', () => { + expect(reducer(Formats.BITMAP /* state */, changeFormat('non-existant mode') /* action */)) + .toBe(Formats.BITMAP); + expect(reducer(Formats.BITMAP /* state */, changeFormat() /* action */)).toBe(Formats.BITMAP); +});