Merge pull request #370 from fsih/convertToBitmap

Convert to bitmap
This commit is contained in:
DD Liu 2018-04-11 15:50:18 -04:00 committed by GitHub
commit bf1d4ca82a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 400 additions and 75 deletions

View file

@ -5,7 +5,7 @@
<desc>Created with Sketch.</desc> <desc>Created with Sketch.</desc>
<defs></defs> <defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd"> <g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="bitmap" fill="#575E75"> <g id="bitmap" fill="#FFFFFF">
<path d="M4,3 L16,3 L16,4 L4,4 L4,3 Z M2,5 L3,5 L3,13 L2,13 L2,5 Z M17,5 L18,5 L18,13 L17,13 L17,5 Z M2,13 L18,13 L18,15 L2,15 L2,13 Z M4,12 L16,12 L16,13 L4,13 L4,12 Z M5,11 L8,11 L8,12 L5,12 L5,11 Z M6,10 L7,10 L7,11 L6,11 L6,10 Z M9,11 L16,11 L16,12 L9,12 L9,11 Z M10,10 L15,10 L15,11 L10,11 L10,10 Z M11,9 L14,9 L14,10 L11,10 L11,9 Z M12,8 L13,8 L13,9 L12,9 L12,8 Z M16,12 L17,12 L17,13 L16,13 L16,12 Z M3,15 L17,15 L17,16 L3,16 L3,15 Z M3,4 L4,4 L4,5 L3,5 L3,4 Z M16,4 L17,4 L17,5 L16,5 L16,4 Z M4,16 L16,16 L16,17 L4,17 L4,16 Z" id="Combined-Shape"></path> <path d="M4,3 L16,3 L16,4 L4,4 L4,3 Z M2,5 L3,5 L3,13 L2,13 L2,5 Z M17,5 L18,5 L18,13 L17,13 L17,5 Z M2,13 L18,13 L18,15 L2,15 L2,13 Z M4,12 L16,12 L16,13 L4,13 L4,12 Z M5,11 L8,11 L8,12 L5,12 L5,11 Z M6,10 L7,10 L7,11 L6,11 L6,10 Z M9,11 L16,11 L16,12 L9,12 L9,11 Z M10,10 L15,10 L15,11 L10,11 L10,10 Z M11,9 L14,9 L14,10 L11,10 L11,9 Z M12,8 L13,8 L13,9 L12,9 L12,8 Z M16,12 L17,12 L17,13 L16,13 L16,12 Z M3,15 L17,15 L17,16 L3,16 L3,15 Z M3,4 L4,4 L4,5 L3,5 L3,4 Z M16,4 L17,4 L17,5 L16,5 L16,4 Z M4,16 L16,16 L16,17 L4,17 L4,16 Z" id="Combined-Shape"></path>
</g> </g>
</g> </g>

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -150,6 +150,10 @@ $border-radius: 0.25rem;
justify-content: space-between; justify-content: space-between;
} }
.hidden {
display: none;
}
.zoom-controls { .zoom-controls {
display: flex; display: flex;
flex-direction: row-reverse; flex-direction: row-reverse;
@ -179,13 +183,19 @@ $border-radius: 0.25rem;
.bitmap-button { .bitmap-button {
display: flex; display: flex;
border-radius: 5px; border-radius: 5px;
background-color: hsla(0, 0%, 0%, .25); background-color: $motion-primary;
padding: calc(2 * $grid-unit); padding: calc(2 * $grid-unit);
line-height: 1.5rem; line-height: 1.5rem;
font-size: calc(3 * $grid-unit); font-size: calc(3 * $grid-unit);
font-weight: bold; font-weight: bold;
color: white;
justify-content: center; justify-content: center;
opacity: .5; opacity: .75;
}
.bitmap-button:active {
background-color: $motion-primary;
opacity: 1;
} }
.bitmap-button-icon { .bitmap-button-icon {

View file

@ -15,7 +15,6 @@ import Button from '../button/button.jsx';
import ButtonGroup from '../button-group/button-group.jsx'; import ButtonGroup from '../button-group/button-group.jsx';
import BrushMode from '../../containers/brush-mode.jsx'; import BrushMode from '../../containers/brush-mode.jsx';
import BufferedInputHOC from '../forms/buffered-input-hoc.jsx'; import BufferedInputHOC from '../forms/buffered-input-hoc.jsx';
import {ComingSoonTooltip} from '../coming-soon/coming-soon.jsx';
import Dropdown from '../dropdown/dropdown.jsx'; import Dropdown from '../dropdown/dropdown.jsx';
import EraserMode from '../../containers/eraser-mode.jsx'; import EraserMode from '../../containers/eraser-mode.jsx';
import FillColorIndicatorComponent from '../../containers/fill-color-indicator.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 StrokeWidthIndicatorComponent from '../../containers/stroke-width-indicator.jsx';
import TextMode from '../../containers/text-mode.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 layout from '../../lib/layout-constants';
import styles from './paint-editor.css'; import styles from './paint-editor.css';
@ -107,6 +108,11 @@ const messages = defineMessages({
defaultMessage: 'Convert to Bitmap', defaultMessage: 'Convert to Bitmap',
description: 'Label for button that converts the paint editor to bitmap mode', description: 'Label for button that converts the paint editor to bitmap mode',
id: 'paint.paintEditor.bitmap' 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 => {
<div className={styles.topAlignRow}> <div className={styles.topAlignRow}>
{/* Modes */} {/* Modes */}
{props.canvas !== null ? ( // eslint-disable-line no-negated-condition {props.canvas !== null ? ( // eslint-disable-line no-negated-condition
<div className={styles.modeSelector}> <div className={isVector(props.format) ? styles.modeSelector : styles.hidden}>
<SelectMode <SelectMode
onUpdateSvg={props.onUpdateSvg} onUpdateSvg={props.onUpdateSvg}
/> />
@ -403,12 +409,11 @@ const PaintEditorComponent = props => {
} }
</div> </div>
<div className={styles.canvasControls}> <div className={styles.canvasControls}>
<ComingSoonTooltip {isVector(props.format) ?
className={styles.bitmapTooltip} <Button
place="top" className={styles.bitmapButton}
tooltipId="bitmap-converter" onClick={props.onSwitchToBitmap}
> >
<div className={styles.bitmapButton}>
<img <img
className={styles.bitmapButtonIcon} className={styles.bitmapButtonIcon}
draggable={false} draggable={false}
@ -417,8 +422,21 @@ const PaintEditorComponent = props => {
<span> <span>
{props.intl.formatMessage(messages.bitmap)} {props.intl.formatMessage(messages.bitmap)}
</span> </span>
</div> </Button> :
</ComingSoonTooltip> <Button
className={styles.bitmapButton}
onClick={props.onSwitchToVector}
>
<img
className={styles.bitmapButtonIcon}
draggable={false}
src={bitmapIcon}
/>
<span>
{props.intl.formatMessage(messages.vector)}
</span>
</Button>
}
{/* Zoom controls */} {/* Zoom controls */}
<InputGroup className={styles.zoomControls}> <InputGroup className={styles.zoomControls}>
<ButtonGroup> <ButtonGroup>
@ -469,6 +487,7 @@ PaintEditorComponent.propTypes = {
canUndo: PropTypes.func.isRequired, canUndo: PropTypes.func.isRequired,
canvas: PropTypes.instanceOf(Element), canvas: PropTypes.instanceOf(Element),
colorInfo: Loupe.propTypes.colorInfo, colorInfo: Loupe.propTypes.colorInfo,
format: PropTypes.oneOf(Object.keys(Formats)).isRequired,
intl: intlShape, intl: intlShape,
isEyeDropping: PropTypes.bool, isEyeDropping: PropTypes.bool,
name: PropTypes.string, name: PropTypes.string,
@ -478,6 +497,8 @@ PaintEditorComponent.propTypes = {
onSendForward: PropTypes.func.isRequired, onSendForward: PropTypes.func.isRequired,
onSendToBack: PropTypes.func.isRequired, onSendToBack: PropTypes.func.isRequired,
onSendToFront: PropTypes.func.isRequired, onSendToFront: PropTypes.func.isRequired,
onSwitchToBitmap: PropTypes.func.isRequired,
onSwitchToVector: PropTypes.func.isRequired,
onUndo: PropTypes.func.isRequired, onUndo: PropTypes.func.isRequired,
onUngroup: PropTypes.func.isRequired, onUngroup: PropTypes.func.isRequired,
onUpdateName: PropTypes.func.isRequired, onUpdateName: PropTypes.func.isRequired,

View file

@ -1,16 +1,19 @@
import paper from '@scratch/paper'; import paper from '@scratch/paper';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import PaintEditorComponent from '../components/paint-editor/paint-editor.jsx'; import PaintEditorComponent from '../components/paint-editor/paint-editor.jsx';
import {changeMode} from '../reducers/modes'; import {changeMode} from '../reducers/modes';
import {changeFormat} from '../reducers/format';
import {undo, redo, undoSnapshot} from '../reducers/undo'; import {undo, redo, undoSnapshot} from '../reducers/undo';
import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items';
import {deactivateEyeDropper} from '../reducers/eye-dropper'; import {deactivateEyeDropper} from '../reducers/eye-dropper';
import {setTextEditTarget} from '../reducers/text-edit-target'; import {setTextEditTarget} from '../reducers/text-edit-target';
import {updateViewBounds} from '../reducers/view-bounds'; 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 {performUndo, performRedo, performSnapshot, shouldShowUndo, shouldShowRedo} from '../helper/undo';
import {bringToFront, sendBackward, sendToBack, bringForward} from '../helper/order'; import {bringToFront, sendBackward, sendToBack, bringForward} from '../helper/order';
import {groupSelection, ungroupSelection} from '../helper/group'; import {groupSelection, ungroupSelection} from '../helper/group';
@ -19,6 +22,8 @@ import {resetZoom, zoomOnSelection} from '../helper/view';
import EyeDropperTool from '../helper/tools/eye-dropper'; import EyeDropperTool from '../helper/tools/eye-dropper';
import Modes from '../lib/modes'; import Modes from '../lib/modes';
import Formats from '../lib/format';
import {isBitmap} from '../lib/format';
import {connect} from 'react-redux'; import {connect} from 'react-redux';
import bindAll from 'lodash.bindall'; import bindAll from 'lodash.bindall';
@ -88,9 +93,20 @@ class PaintEditor extends React.Component {
const oldCenter = paper.project.view.center.clone(); const oldCenter = paper.project.view.center.clone();
resetZoom(); 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; const bounds = paper.project.activeLayer.bounds;
this.props.onUpdateSvg( this.props.onUpdateSvg(
paper.project.exportSVG({ paper.project.exportSVG({
asString: true, asString: true,
@ -101,9 +117,10 @@ class PaintEditor extends React.Component {
paper.project.view.center.y - bounds.y); paper.project.view.center.y - bounds.y);
showGuideLayers(guideLayers); showGuideLayers(guideLayers);
if (raster) raster.remove();
if (!skipSnapshot) { if (!skipSnapshot) {
performSnapshot(this.props.undoSnapshot); performSnapshot(this.props.undoSnapshot, this.props.format);
} }
// Restore old zoom // Restore old zoom
@ -231,6 +248,7 @@ class PaintEditor extends React.Component {
canUndo={this.canUndo} canUndo={this.canUndo}
canvas={this.state.canvas} canvas={this.state.canvas}
colorInfo={this.state.colorInfo} colorInfo={this.state.colorInfo}
format={this.props.format}
isEyeDropping={this.props.isEyeDropping} isEyeDropping={this.props.isEyeDropping}
name={this.props.name} name={this.props.name}
rotationCenterX={this.props.rotationCenterX} rotationCenterX={this.props.rotationCenterX}
@ -246,6 +264,8 @@ class PaintEditor extends React.Component {
onSendForward={this.handleSendForward} onSendForward={this.handleSendForward}
onSendToBack={this.handleSendToBack} onSendToBack={this.handleSendToBack}
onSendToFront={this.handleSendToFront} onSendToFront={this.handleSendToFront}
onSwitchToBitmap={this.props.handleSwitchToBitmap}
onSwitchToVector={this.props.handleSwitchToVector}
onUndo={this.handleUndo} onUndo={this.handleUndo}
onUngroup={this.handleUngroup} onUngroup={this.handleUngroup}
onUpdateName={this.props.onUpdateName} onUpdateName={this.props.onUpdateName}
@ -261,6 +281,9 @@ class PaintEditor extends React.Component {
PaintEditor.propTypes = { PaintEditor.propTypes = {
changeColorToEyeDropper: PropTypes.func, changeColorToEyeDropper: PropTypes.func,
clearSelectedItems: PropTypes.func.isRequired, clearSelectedItems: PropTypes.func.isRequired,
format: PropTypes.oneOf(Object.keys(Formats)).isRequired,
handleSwitchToBitmap: PropTypes.func.isRequired,
handleSwitchToVector: PropTypes.func.isRequired,
isEyeDropping: PropTypes.bool, isEyeDropping: PropTypes.bool,
name: PropTypes.string, name: PropTypes.string,
onDeactivateEyeDropper: PropTypes.func.isRequired, onDeactivateEyeDropper: PropTypes.func.isRequired,
@ -291,6 +314,7 @@ PaintEditor.propTypes = {
const mapStateToProps = state => ({ const mapStateToProps = state => ({
changeColorToEyeDropper: state.scratchPaint.color.eyeDropper.callback, changeColorToEyeDropper: state.scratchPaint.color.eyeDropper.callback,
clipboardItems: state.scratchPaint.clipboard.items, clipboardItems: state.scratchPaint.clipboard.items,
format: state.scratchPaint.format,
isEyeDropping: state.scratchPaint.color.eyeDropper.active, isEyeDropping: state.scratchPaint.color.eyeDropper.active,
pasteOffset: state.scratchPaint.clipboard.pasteOffset, pasteOffset: state.scratchPaint.clipboard.pasteOffset,
previousTool: state.scratchPaint.color.eyeDropper.previousTool, previousTool: state.scratchPaint.color.eyeDropper.previousTool,
@ -323,6 +347,12 @@ const mapDispatchToProps = dispatch => ({
clearSelectedItems: () => { clearSelectedItems: () => {
dispatch(clearSelectedItems()); dispatch(clearSelectedItems());
}, },
handleSwitchToBitmap: () => {
dispatch(changeFormat(Formats.BITMAP));
},
handleSwitchToVector: () => {
dispatch(changeFormat(Formats.VECTOR));
},
removeTextEditTarget: () => { removeTextEditTarget: () => {
dispatch(setTextEditTarget()); dispatch(setTextEditTarget());
}, },
@ -333,11 +363,11 @@ const mapDispatchToProps = dispatch => ({
// set redux values to default for eye dropper reducer // set redux values to default for eye dropper reducer
dispatch(deactivateEyeDropper()); dispatch(deactivateEyeDropper());
}, },
onUndo: () => { onUndo: format => {
dispatch(undo()); dispatch(undo(format));
}, },
onRedo: () => { onRedo: format => {
dispatch(redo()); dispatch(redo(format));
}, },
undoSnapshot: snapshot => { undoSnapshot: snapshot => {
dispatch(undoSnapshot(snapshot)); dispatch(undoSnapshot(snapshot));

View file

@ -4,7 +4,4 @@
margin: auto; margin: auto;
position: absolute; position: absolute;
background-color: #fff; 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;
} }

View file

@ -3,13 +3,15 @@ import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import {connect} from 'react-redux'; import {connect} from 'react-redux';
import paper from '@scratch/paper'; import paper from '@scratch/paper';
import Formats from '../lib/format';
import Modes from '../lib/modes'; import Modes from '../lib/modes';
import log from '../log/log'; import log from '../log/log';
import {trim} from '../helper/bitmap';
import {performSnapshot} from '../helper/undo'; import {performSnapshot} from '../helper/undo';
import {undoSnapshot, clearUndoState} from '../reducers/undo'; import {undoSnapshot, clearUndoState} from '../reducers/undo';
import {isGroup, ungroupItems} from '../helper/group'; 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 {deleteSelection, getSelectedLeafItems} from '../helper/selection';
import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items'; import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items';
import {pan, resetZoom, zoomOnFixedPoint} from '../helper/view'; import {pan, resetZoom, zoomOnFixedPoint} from '../helper/view';
@ -17,6 +19,9 @@ import {ensureClockwise} from '../helper/math';
import {clearHoveredItem} from '../reducers/hover'; import {clearHoveredItem} from '../reducers/hover';
import {clearPasteOffset} from '../reducers/clipboard'; import {clearPasteOffset} from '../reducers/clipboard';
import {updateViewBounds} from '../reducers/view-bounds'; import {updateViewBounds} from '../reducers/view-bounds';
import {changeFormat} from '../reducers/format';
import {isVector, isBitmap} from '../lib/format';
import styles from './paper-canvas.css'; import styles from './paper-canvas.css';
@ -24,15 +29,23 @@ class PaperCanvas extends React.Component {
constructor (props) { constructor (props) {
super(props); super(props);
bindAll(this, [ bindAll(this, [
'convertToBitmap',
'convertToVector',
'setCanvas', 'setCanvas',
'importSvg', 'importSvg',
'handleKeyDown', 'handleKeyDown',
'handleWheel' 'handleWheel',
'switchCostume'
]); ]);
} }
componentDidMount () { componentDidMount () {
document.addEventListener('keydown', this.handleKeyDown); document.addEventListener('keydown', this.handleKeyDown);
paper.setup(this.canvas); paper.setup(this.canvas);
const context = this.canvas.getContext('2d');
context.webkitImageSmoothingEnabled = false;
context.imageSmoothingEnabled = false;
// Don't show handles by default // Don't show handles by default
paper.settings.handleSize = 0; paper.settings.handleSize = 0;
// Make layers. // Make layers.
@ -40,31 +53,16 @@ class PaperCanvas extends React.Component {
if (this.props.svg) { if (this.props.svg) {
this.importSvg(this.props.svg, this.props.rotationCenterX, this.props.rotationCenterY); this.importSvg(this.props.svg, this.props.rotationCenterX, this.props.rotationCenterY);
} else { } else {
performSnapshot(this.props.undoSnapshot); performSnapshot(this.props.undoSnapshot, this.props.format);
} }
} }
componentWillReceiveProps (newProps) { componentWillReceiveProps (newProps) {
if (this.props.svgId === newProps.svgId) return; if (this.props.svgId !== newProps.svgId) {
for (const layer of paper.project.layers) { this.switchCostume(newProps.svg, newProps.rotationCenterX, newProps.rotationCenterY);
if (!layer.data.isBackgroundGuideLayer) { } else if (isVector(this.props.format) && newProps.format === Formats.BITMAP) {
layer.removeChildren(); this.convertToBitmap();
} } else if (isBitmap(this.props.format) && newProps.format === Formats.VECTOR) {
} this.convertToVector();
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);
} }
} }
componentWillUnmount () { 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) { importSvg (svg, rotationCenterX, rotationCenterY) {
const paperCanvas = this; const paperCanvas = this;
// Pre-process SVG to prevent parsing errors (discussion from #213) // Pre-process SVG to prevent parsing errors (discussion from #213)
@ -115,7 +160,7 @@ class PaperCanvas extends React.Component {
if (!item) { if (!item) {
log.error('SVG import failed:'); log.error('SVG import failed:');
log.info(svg); log.info(svg);
performSnapshot(paperCanvas.props.undoSnapshot); performSnapshot(paperCanvas.props.undoSnapshot, paperCanvas.props.format);
return; return;
} }
const itemWidth = item.bounds.width; const itemWidth = item.bounds.width;
@ -157,7 +202,7 @@ class PaperCanvas extends React.Component {
ungroupItems([item]); ungroupItems([item]);
} }
performSnapshot(paperCanvas.props.undoSnapshot); performSnapshot(paperCanvas.props.undoSnapshot, paperCanvas.props.format);
} }
}); });
} }
@ -209,10 +254,12 @@ class PaperCanvas extends React.Component {
PaperCanvas.propTypes = { PaperCanvas.propTypes = {
canvasRef: PropTypes.func, canvasRef: PropTypes.func,
changeFormat: PropTypes.func.isRequired,
clearHoveredItem: PropTypes.func.isRequired, clearHoveredItem: PropTypes.func.isRequired,
clearPasteOffset: PropTypes.func.isRequired, clearPasteOffset: PropTypes.func.isRequired,
clearSelectedItems: PropTypes.func.isRequired, clearSelectedItems: PropTypes.func.isRequired,
clearUndo: PropTypes.func.isRequired, clearUndo: PropTypes.func.isRequired,
format: PropTypes.oneOf(Object.keys(Formats)).isRequired,
mode: PropTypes.oneOf(Object.keys(Modes)), mode: PropTypes.oneOf(Object.keys(Modes)),
onUpdateSvg: PropTypes.func.isRequired, onUpdateSvg: PropTypes.func.isRequired,
rotationCenterX: PropTypes.number, rotationCenterX: PropTypes.number,
@ -224,7 +271,8 @@ PaperCanvas.propTypes = {
updateViewBounds: PropTypes.func.isRequired updateViewBounds: PropTypes.func.isRequired
}; };
const mapStateToProps = state => ({ const mapStateToProps = state => ({
mode: state.scratchPaint.mode mode: state.scratchPaint.mode,
format: state.scratchPaint.format
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({
undoSnapshot: snapshot => { undoSnapshot: snapshot => {
@ -245,6 +293,9 @@ const mapDispatchToProps = dispatch => ({
clearPasteOffset: () => { clearPasteOffset: () => {
dispatch(clearPasteOffset()); dispatch(clearPasteOffset());
}, },
changeFormat: format => {
dispatch(changeFormat(format));
},
updateViewBounds: matrix => { updateViewBounds: matrix => {
dispatch(updateViewBounds(matrix)); dispatch(updateViewBounds(matrix));
} }

37
src/helper/bitmap.js Normal file
View file

@ -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
};

View file

@ -1,4 +1,5 @@
import paper from '@scratch/paper'; import paper from '@scratch/paper';
import rasterSrc from './transparent.png';
import log from '../log/log'; import log from '../log/log';
const _getLayer = function (layerString) { const _getLayer = function (layerString) {
@ -7,33 +8,66 @@ const _getLayer = function (layerString) {
return layer; return layer;
} }
} }
log.error(`Didn't find layer ${layerString}`);
}; };
const _getPaintingLayer = function () { const _getPaintingLayer = function () {
return _getLayer('isPaintingLayer'); 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 () { const _getBackgroundGuideLayer = function () {
return _getLayer('isBackgroundGuideLayer'); return _getLayer('isBackgroundGuideLayer');
}; };
const _makeGuideLayer = function () {
const guideLayer = new paper.Layer();
guideLayer.data.isGuideLayer = true;
return guideLayer;
};
const getGuideLayer = function () { 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. * 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. * @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 backgroundGuideLayer = _getBackgroundGuideLayer();
const guideLayer = getGuideLayer(); const guideLayer = getGuideLayer();
guideLayer.remove(); guideLayer.remove();
backgroundGuideLayer.remove(); backgroundGuideLayer.remove();
let rasterLayer;
if (includeRaster) {
rasterLayer = _getLayer('isRasterLayer');
rasterLayer.remove();
}
return { return {
guideLayer: guideLayer, guideLayer: guideLayer,
backgroundGuideLayer: backgroundGuideLayer backgroundGuideLayer: backgroundGuideLayer,
rasterLayer: rasterLayer
}; };
}; };
@ -45,6 +79,11 @@ const hideGuideLayers = function () {
const showGuideLayers = function (guideLayers) { const showGuideLayers = function (guideLayers) {
const backgroundGuideLayer = guideLayers.backgroundGuideLayer; const backgroundGuideLayer = guideLayers.backgroundGuideLayer;
const guideLayer = guideLayers.guideLayer; const guideLayer = guideLayers.guideLayer;
const rasterLayer = guideLayers.rasterLayer;
if (rasterLayer && !rasterLayer.index) {
paper.project.addLayer(rasterLayer);
rasterLayer.sendToBack();
}
if (!backgroundGuideLayer.index) { if (!backgroundGuideLayer.index) {
paper.project.addLayer(backgroundGuideLayer); paper.project.addLayer(backgroundGuideLayer);
backgroundGuideLayer.sendToBack(); 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 _makePaintingLayer = function () {
const paintingLayer = new paper.Layer(); const paintingLayer = new paper.Layer();
paintingLayer.data.isPaintingLayer = true; paintingLayer.data.isPaintingLayer = true;
return paintingLayer; return paintingLayer;
}; };
const _makeRasterLayer = function () {
const rasterLayer = new paper.Layer();
rasterLayer.data.isRasterLayer = true;
clearRaster();
return rasterLayer;
};
const _makeBackgroundPaper = function (width, height, color) { const _makeBackgroundPaper = function (width, height, color) {
// creates a checkerboard path of width * height squares in color on white // creates a checkerboard path of width * height squares in color on white
let x = 0; let x = 0;
@ -140,6 +180,7 @@ const _makeBackgroundGuideLayer = function () {
const setupLayers = function () { const setupLayers = function () {
const backgroundGuideLayer = _makeBackgroundGuideLayer(); const backgroundGuideLayer = _makeBackgroundGuideLayer();
_makeRasterLayer();
const paintLayer = _makePaintingLayer(); const paintLayer = _makePaintingLayer();
const guideLayer = _makeGuideLayer(); const guideLayer = _makeGuideLayer();
backgroundGuideLayer.sendToBack(); backgroundGuideLayer.sendToBack();
@ -151,5 +192,7 @@ export {
hideGuideLayers, hideGuideLayers,
showGuideLayers, showGuideLayers,
getGuideLayer, getGuideLayer,
clearRaster,
getRaster,
setupLayers setupLayers
}; };

BIN
src/helper/transparent.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

View file

@ -1,18 +1,28 @@
// undo functionality // undo functionality
// modifed from https://github.com/memononen/stylii // modifed from https://github.com/memononen/stylii
import paper from '@scratch/paper'; 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(); const guideLayers = hideGuideLayers();
dispatchPerformSnapshot({ dispatchPerformSnapshot({
json: paper.project.exportJSON({asString: false}) json: paper.project.exportJSON({asString: false}),
paintEditorFormat: format
}); });
showGuideLayers(guideLayers); showGuideLayers(guideLayers);
}; };
const _restore = function (entry, setSelectedItems, onUpdateSvg) { 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) { if (!layer.data.isBackgroundGuideLayer) {
layer.removeChildren(); layer.removeChildren();
layer.remove(); layer.remove();
@ -21,21 +31,38 @@ const _restore = function (entry, setSelectedItems, onUpdateSvg) {
paper.project.importJSON(entry.json); paper.project.importJSON(entry.json);
setSelectedItems(); setSelectedItems();
onUpdateSvg(true /* skipSnapshot */); getRaster().onLoad = function () {
onUpdateSvg(true /* skipSnapshot */);
};
if (getRaster().loaded) {
getRaster().onLoad();
}
}; };
const performUndo = function (undoState, dispatchPerformUndo, setSelectedItems, onUpdateSvg) { const performUndo = function (undoState, dispatchPerformUndo, setSelectedItems, onUpdateSvg) {
if (undoState.pointer > 0) { if (undoState.pointer > 0) {
_restore(undoState.stack[undoState.pointer - 1], setSelectedItems, onUpdateSvg); const state = undoState.stack[undoState.pointer - 1];
dispatchPerformUndo(); _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) { const performRedo = function (undoState, dispatchPerformRedo, setSelectedItems, onUpdateSvg) {
if (undoState.pointer >= 0 && undoState.pointer < undoState.stack.length - 1) { if (undoState.pointer >= 0 && undoState.pointer < undoState.stack.length - 1) {
_restore(undoState.stack[undoState.pointer + 1], setSelectedItems, onUpdateSvg); const state = undoState.stack[undoState.pointer + 1];
dispatchPerformRedo(); _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);
} }
}; };

23
src/lib/format.js Normal file
View file

@ -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
};

37
src/reducers/format.js Normal file
View file

@ -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
};

View file

@ -4,6 +4,7 @@ import brushModeReducer from './brush-mode';
import eraserModeReducer from './eraser-mode'; import eraserModeReducer from './eraser-mode';
import colorReducer from './color'; import colorReducer from './color';
import clipboardReducer from './clipboard'; import clipboardReducer from './clipboard';
import formatReducer from './format';
import hoverReducer from './hover'; import hoverReducer from './hover';
import modalsReducer from './modals'; import modalsReducer from './modals';
import selectedItemReducer from './selected-items'; import selectedItemReducer from './selected-items';
@ -17,6 +18,7 @@ export default combineReducers({
color: colorReducer, color: colorReducer,
clipboard: clipboardReducer, clipboard: clipboardReducer,
eraserMode: eraserModeReducer, eraserMode: eraserModeReducer,
format: formatReducer,
hoveredItemId: hoverReducer, hoveredItemId: hoverReducer,
modals: modalsReducer, modals: modalsReducer,
selectedItems: selectedItemReducer, selectedItems: selectedItemReducer,

View file

@ -63,14 +63,24 @@ const undoSnapshot = function (snapshot) {
snapshot: 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 { 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 { return {
type: REDO type: REDO,
format: format
}; };
}; };
const clearUndoState = function () { const clearUndoState = function () {
@ -85,5 +95,7 @@ export {
redo, redo,
undoSnapshot, undoSnapshot,
clearUndoState, clearUndoState,
MAX_STACK_SIZE MAX_STACK_SIZE,
UNDO,
REDO
}; };

View file

@ -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);
});