mirror of
https://github.com/scratchfoundation/scratch-paint.git
synced 2024-12-22 21:42:30 -05:00
commit
bf1d4ca82a
15 changed files with 400 additions and 75 deletions
|
@ -5,7 +5,7 @@
|
|||
<desc>Created with Sketch.</desc>
|
||||
<defs></defs>
|
||||
<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>
|
||||
</g>
|
||||
</g>
|
||||
|
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
@ -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 {
|
||||
|
|
|
@ -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 => {
|
|||
<div className={styles.topAlignRow}>
|
||||
{/* Modes */}
|
||||
{props.canvas !== null ? ( // eslint-disable-line no-negated-condition
|
||||
<div className={styles.modeSelector}>
|
||||
<div className={isVector(props.format) ? styles.modeSelector : styles.hidden}>
|
||||
<SelectMode
|
||||
onUpdateSvg={props.onUpdateSvg}
|
||||
/>
|
||||
|
@ -403,12 +409,11 @@ const PaintEditorComponent = props => {
|
|||
}
|
||||
</div>
|
||||
<div className={styles.canvasControls}>
|
||||
<ComingSoonTooltip
|
||||
className={styles.bitmapTooltip}
|
||||
place="top"
|
||||
tooltipId="bitmap-converter"
|
||||
>
|
||||
<div className={styles.bitmapButton}>
|
||||
{isVector(props.format) ?
|
||||
<Button
|
||||
className={styles.bitmapButton}
|
||||
onClick={props.onSwitchToBitmap}
|
||||
>
|
||||
<img
|
||||
className={styles.bitmapButtonIcon}
|
||||
draggable={false}
|
||||
|
@ -417,8 +422,21 @@ const PaintEditorComponent = props => {
|
|||
<span>
|
||||
{props.intl.formatMessage(messages.bitmap)}
|
||||
</span>
|
||||
</div>
|
||||
</ComingSoonTooltip>
|
||||
</Button> :
|
||||
<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 */}
|
||||
<InputGroup className={styles.zoomControls}>
|
||||
<ButtonGroup>
|
||||
|
@ -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,
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
37
src/helper/bitmap.js
Normal file
37
src/helper/bitmap.js
Normal 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
|
||||
};
|
|
@ -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
|
||||
};
|
||||
|
|
BIN
src/helper/transparent.png
Normal file
BIN
src/helper/transparent.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 414 B |
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
23
src/lib/format.js
Normal file
23
src/lib/format.js
Normal 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
37
src/reducers/format.js
Normal 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
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
};
|
||||
|
|
35
test/unit/format-reducer.test.js
Normal file
35
test/unit/format-reducer.test.js
Normal 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);
|
||||
});
|
Loading…
Reference in a new issue