Update import and export types (#412)

* Import/export bitmap
* Fix playground and readme
* Use constants instead of changing zoom level for every import/export
This commit is contained in:
DD Liu 2018-05-01 16:18:24 -04:00 committed by GitHub
parent 06a53d305c
commit 89133f42e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 110 additions and 128 deletions

View file

@ -60,7 +60,7 @@ Then go to [http://localhost:8601](http://localhost:8601). 601 is supposed to lo
### How to include in your own Node.js App
For an example of how to use scratch-paint as a library, check out the `scratch-paint/src/playground` directory.
In `playground.jsx`, you can change the image that is passed in (which may either be nothing, an SVG string or an HTMLImageElement) and edit the handler `onUpdateImage`, which is called with the new image (either an SVG string or an HTMLCanvasElement) each time the vector drawing is edited.
In `playground.jsx`, you can change the image that is passed in (which may either be nothing, an SVG string or a base64 data URI) and edit the handler `onUpdateImage`, which is called with the new image (either an SVG string or an ImageData) each time the vector drawing is edited.
If the `imageId` parameter changes, then the paint editor will be cleared, the undo stack reset, and the image re-imported.
@ -73,6 +73,7 @@ import PaintEditor from 'scratch-paint';
<PaintEditor
image={optionalImage}
imageId={optionalId}
imageFormat='svg' // 'svg', 'png', or 'jpg'
rotationCenterX={optionalCenterPointXRelativeToTopLeft}
rotationCenterY={optionalCenterPointYRelativeToTopLeft}
onUpdateImage={handleUpdateImageFunction}

View file

@ -434,6 +434,7 @@ const PaintEditorComponent = props => {
<PaperCanvas
canvasRef={props.setCanvas}
image={props.image}
imageFormat={props.imageFormat}
imageId={props.imageId}
rotationCenterX={props.rotationCenterX}
rotationCenterY={props.rotationCenterY}
@ -541,6 +542,7 @@ PaintEditorComponent.propTypes = {
PropTypes.string,
PropTypes.instanceOf(HTMLImageElement)
]),
imageFormat: PropTypes.string,
imageId: PropTypes.string,
intl: intlShape,
isEyeDropping: PropTypes.bool,

View file

@ -13,12 +13,13 @@ import {setTextEditTarget} from '../reducers/text-edit-target';
import {updateViewBounds} from '../reducers/view-bounds';
import {getRaster, hideGuideLayers, showGuideLayers} from '../helper/layer';
import {trim} from '../helper/bitmap';
import {getHitBounds} 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';
import {scaleWithStrokes} from '../helper/math';
import {getSelectedLeafItems} from '../helper/selection';
import {ART_BOARD_WIDTH, ART_BOARD_HEIGHT, SVG_ART_BOARD_WIDTH, SVG_ART_BOARD_HEIGHT} from '../helper/view';
import {resetZoom, zoomOnSelection} from '../helper/view';
import EyeDropperTool from '../helper/tools/eye-dropper';
@ -119,29 +120,19 @@ class PaintEditor extends React.Component {
}
}
handleUpdateImage (skipSnapshot) {
// Store the zoom/pan and restore it after snapshotting
// TODO Only doing this because snapshotting at zoom/pan makes export wrong
const oldZoom = paper.project.view.zoom;
const oldCenter = paper.project.view.center.clone();
resetZoom();
let raster;
if (isBitmap(this.props.format)) {
raster = trim(getRaster());
raster.remove();
const rect = getHitBounds(getRaster());
this.props.onUpdateImage(
false /* isVector */,
raster.canvas,
paper.project.view.center.x - raster.bounds.x,
paper.project.view.center.y - raster.bounds.y);
getRaster().getImageData(rect),
(ART_BOARD_WIDTH / 2) - rect.x,
(ART_BOARD_HEIGHT / 2) - rect.y);
} else if (isVector(this.props.format)) {
const guideLayers = hideGuideLayers(true /* includeRaster */);
// Export at 0.5x
scaleWithStrokes(paper.project.activeLayer, .5, new paper.Point());
const bounds = paper.project.activeLayer.bounds;
this.props.onUpdateImage(
true /* isVector */,
paper.project.exportSVG({
@ -149,9 +140,8 @@ class PaintEditor extends React.Component {
bounds: 'content',
matrix: new paper.Matrix().translate(-bounds.x, -bounds.y)
}),
(paper.project.view.center.x / 2) - bounds.x,
(paper.project.view.center.y / 2) - bounds.y);
(SVG_ART_BOARD_WIDTH / 2) - bounds.x,
(SVG_ART_BOARD_HEIGHT / 2) - bounds.y);
scaleWithStrokes(paper.project.activeLayer, 2, new paper.Point());
paper.project.activeLayer.applyMatrix = true;
@ -161,10 +151,6 @@ class PaintEditor extends React.Component {
if (!skipSnapshot) {
performSnapshot(this.props.undoSnapshot, this.props.format);
}
// Restore old zoom
paper.project.view.zoom = oldZoom;
paper.project.view.center = oldCenter;
}
handleUndo () {
performUndo(this.props.undoState, this.props.onUndo, this.props.setSelectedItems, this.handleUpdateImage);
@ -289,6 +275,7 @@ class PaintEditor extends React.Component {
colorInfo={this.state.colorInfo}
format={this.props.format}
image={this.props.image}
imageFormat={this.props.imageFormat}
imageId={this.props.imageId}
isEyeDropping={this.props.isEyeDropping}
name={this.props.name}
@ -321,13 +308,14 @@ PaintEditor.propTypes = {
changeColorToEyeDropper: PropTypes.func,
changeMode: PropTypes.func.isRequired,
clearSelectedItems: PropTypes.func.isRequired,
format: PropTypes.oneOf(Object.keys(Formats)),
format: PropTypes.oneOf(Object.keys(Formats)), // Internal, up-to-date data format
handleSwitchToBitmap: PropTypes.func.isRequired,
handleSwitchToVector: PropTypes.func.isRequired,
image: PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(HTMLImageElement)
]),
imageFormat: PropTypes.string, // The incoming image's data format, used during import
imageId: PropTypes.string,
isEyeDropping: PropTypes.bool,
mode: PropTypes.oneOf(Object.keys(Modes)).isRequired,

View file

@ -13,7 +13,7 @@ import {undoSnapshot, clearUndoState} from '../reducers/undo';
import {clearRaster, getRaster, setupLayers, hideGuideLayers, showGuideLayers} from '../helper/layer';
import {deleteSelection, getSelectedLeafItems} from '../helper/selection';
import {clearSelectedItems, setSelectedItems} from '../reducers/selected-items';
import {clampViewBounds, pan, resetZoom, zoomOnFixedPoint} from '../helper/view';
import {ART_BOARD_WIDTH, ART_BOARD_HEIGHT, pan, resetZoom, zoomOnFixedPoint} from '../helper/view';
import {ensureClockwise, scaleWithStrokes} from '../helper/math';
import {clearHoveredItem} from '../reducers/hover';
import {clearPasteOffset} from '../reducers/clipboard';
@ -28,7 +28,6 @@ class PaperCanvas extends React.Component {
constructor (props) {
super(props);
bindAll(this, [
'checkFormat',
'convertToBitmap',
'convertToVector',
'setCanvas',
@ -41,8 +40,7 @@ class PaperCanvas extends React.Component {
componentDidMount () {
document.addEventListener('keydown', this.handleKeyDown);
paper.setup(this.canvas);
paper.view.zoom = .5;
clampViewBounds();
resetZoom();
const context = this.canvas.getContext('2d');
context.webkitImageSmoothingEnabled = false;
@ -52,26 +50,13 @@ class PaperCanvas extends React.Component {
paper.settings.handleSize = 0;
// Make layers.
setupLayers();
if (this.props.image) {
if (isBitmap(this.checkFormat(this.props.image))) {
// import bitmap
this.props.changeFormat(Formats.BITMAP_SKIP_CONVERT);
performSnapshot(this.props.undoSnapshot, this.props.format);
getRaster().drawImage(
this.props.image,
paper.project.view.center.x - this.props.rotationCenterX,
paper.project.view.center.y - this.props.rotationCenterY);
} else if (isVector(this.checkFormat(this.props.image))) {
this.props.changeFormat(Formats.VECTOR_SKIP_CONVERT);
this.importSvg(this.props.image, this.props.rotationCenterX, this.props.rotationCenterY);
}
} else {
performSnapshot(this.props.undoSnapshot, this.props.format);
}
this.importImage(
this.props.imageFormat, this.props.image, this.props.rotationCenterX, this.props.rotationCenterY);
}
componentWillReceiveProps (newProps) {
if (this.props.imageId !== newProps.imageId) {
this.switchCostume(newProps.image, newProps.rotationCenterX, newProps.rotationCenterY);
this.switchCostume(
newProps.imageFormat, newProps.image, 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) {
@ -97,13 +82,8 @@ class PaperCanvas extends React.Component {
convertToBitmap () {
// @todo if the active layer contains only rasters, drawing them directly to the raster layer
// would be more efficient.
// Export svg
// Store the zoom/pan and restore it after snapshotting
const oldZoom = paper.project.view.zoom;
const oldCenter = paper.project.view.center.clone();
paper.project.view.zoom = 1;
// Export svg
const guideLayers = hideGuideLayers(true /* includeRaster */);
const bounds = paper.project.activeLayer.bounds;
const svg = paper.project.exportSVG({
@ -111,7 +91,7 @@ class PaperCanvas extends React.Component {
matrix: new paper.Matrix().translate(-bounds.x, -bounds.y)
});
showGuideLayers(guideLayers);
// Get rid of anti-aliasing
// @todo get crisp text?
svg.setAttribute('shape-rendering', 'crispEdges');
@ -120,22 +100,14 @@ class PaperCanvas extends React.Component {
// Put anti-aliased SVG into image, and dump image back into canvas
const img = new Image();
img.onload = () => {
const raster = new paper.Raster(img);
raster.remove();
raster.onLoad = () => {
const subCanvas = raster.canvas;
getRaster().drawImage(
subCanvas,
new paper.Point(Math.floor(bounds.topLeft.x), Math.floor(bounds.topLeft.y)));
paper.project.activeLayer.removeChildren();
this.props.onUpdateImage();
};
getRaster().drawImage(
img,
new paper.Point(Math.floor(bounds.topLeft.x), Math.floor(bounds.topLeft.y)));
paper.project.activeLayer.removeChildren();
this.props.onUpdateImage();
};
img.src = `data:image/svg+xml;charset=utf-8,${svgString}`;
// Restore old zoom
paper.project.view.zoom = oldZoom;
paper.project.view.center = oldCenter;
}
convertToVector () {
this.props.clearSelectedItems();
@ -148,13 +120,7 @@ class PaperCanvas extends React.Component {
clearRaster();
this.props.onUpdateImage();
}
checkFormat (image) {
if (image instanceof HTMLImageElement) return Formats.BITMAP;
if (typeof image === 'string') return Formats.VECTOR;
log.error(`Image could not be read.`);
return null;
}
switchCostume (image, rotationCenterX, rotationCenterY) {
switchCostume (format, image, rotationCenterX, rotationCenterY) {
for (const layer of paper.project.layers) {
if (layer.data.isRasterLayer) {
clearRaster();
@ -166,31 +132,38 @@ class PaperCanvas extends React.Component {
this.props.clearSelectedItems();
this.props.clearHoveredItem();
this.props.clearPasteOffset();
if (image) {
if (isBitmap(this.checkFormat(image))) {
// import bitmap
this.props.changeFormat(Formats.BITMAP_SKIP_CONVERT);
this.importImage(format, image, rotationCenterX, rotationCenterY);
}
importImage (format, image, rotationCenterX, rotationCenterY) {
if (!image) {
this.props.changeFormat(Formats.VECTOR_SKIP_CONVERT);
performSnapshot(this.props.undoSnapshot, Formats.VECTOR_SKIP_CONVERT);
return;
}
if (format === 'jpg' || format === 'png') {
// import bitmap
this.props.changeFormat(Formats.BITMAP_SKIP_CONVERT);
const imgElement = new Image();
imgElement.onload = () => {
getRaster().drawImage(
image,
paper.project.view.center.x - rotationCenterX,
paper.project.view.center.y - rotationCenterY);
performSnapshot(this.props.undoSnapshot, this.props.format);
} else if (isVector(this.checkFormat(image))) {
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(image, rotationCenterX, rotationCenterY);
paper.project.view.zoom = oldZoom;
paper.project.view.center = oldCenter;
} else {
log.error(`Couldn't open image.`);
performSnapshot(this.props.undoSnapshot, this.props.format);
}
imgElement,
(ART_BOARD_WIDTH / 2) - rotationCenterX,
(ART_BOARD_HEIGHT / 2) - rotationCenterY);
getRaster().drawImage(
imgElement,
(ART_BOARD_WIDTH / 2) - rotationCenterX,
(ART_BOARD_HEIGHT / 2) - rotationCenterY);
performSnapshot(this.props.undoSnapshot, Formats.BITMAP_SKIP_CONVERT);
};
imgElement.src = image;
} else if (format === 'svg') {
this.props.changeFormat(Formats.VECTOR_SKIP_CONVERT);
this.importSvg(image, rotationCenterX, rotationCenterY);
} else {
performSnapshot(this.props.undoSnapshot, this.props.format);
log.error(`Didn't recognize format: ${format}. Use 'jpg', 'png' or 'svg'.`);
this.props.changeFormat(Formats.VECTOR_SKIP_CONVERT);
performSnapshot(this.props.undoSnapshot, Formats.VECTOR_SKIP_CONVERT);
}
}
importSvg (svg, rotationCenterX, rotationCenterY) {
@ -225,7 +198,8 @@ class PaperCanvas extends React.Component {
if (!item) {
log.error('SVG import failed:');
log.info(svg);
performSnapshot(paperCanvas.props.undoSnapshot, paperCanvas.props.format);
this.props.changeFormat(Formats.VECTOR_SKIP_CONVERT);
performSnapshot(paperCanvas.props.undoSnapshot, Formats.VECTOR_SKIP_CONVERT);
return;
}
const itemWidth = item.bounds.width;
@ -245,7 +219,7 @@ class PaperCanvas extends React.Component {
}
// Reduce single item nested in groups
if (item.children && item.children.length === 1) {
if (item instanceof paper.Group && item.children.length === 1) {
item = item.reduce();
}
@ -257,15 +231,15 @@ class PaperCanvas extends React.Component {
if (viewBox && viewBox.length >= 2 && !isNaN(viewBox[0]) && !isNaN(viewBox[1])) {
rotationPoint = rotationPoint.subtract(viewBox[0], viewBox[1]);
}
item.translate(paper.project.view.center
item.translate(new paper.Point(ART_BOARD_WIDTH / 2, ART_BOARD_HEIGHT / 2)
.subtract(rotationPoint.multiply(2)));
} else {
// Center
item.translate(paper.project.view.center
.subtract(itemWidth / 2, itemHeight / 2));
item.translate(new paper.Point(ART_BOARD_WIDTH / 2, ART_BOARD_HEIGHT / 2)
.subtract(itemWidth, itemHeight));
}
performSnapshot(paperCanvas.props.undoSnapshot, paperCanvas.props.format);
performSnapshot(paperCanvas.props.undoSnapshot, Formats.VECTOR_SKIP_CONVERT);
}
});
}
@ -322,11 +296,12 @@ PaperCanvas.propTypes = {
clearPasteOffset: PropTypes.func.isRequired,
clearSelectedItems: PropTypes.func.isRequired,
clearUndo: PropTypes.func.isRequired,
format: PropTypes.oneOf(Object.keys(Formats)),
format: PropTypes.oneOf(Object.keys(Formats)), // Internal, up-to-date data format
image: PropTypes.oneOfType([
PropTypes.string,
PropTypes.instanceOf(HTMLImageElement)
]),
imageFormat: PropTypes.string, // The incoming image's data format, used during import
imageId: PropTypes.string,
mode: PropTypes.oneOf(Object.keys(Modes)),
onUpdateImage: PropTypes.func.isRequired,

View file

@ -124,8 +124,8 @@ const columnBlank_ = function (imageData, width, x, top, bottom) {
};
// Adapted from Tim Down's https://gist.github.com/timdown/021d9c8f2aabc7092df564996f5afbbf
// Trims transparent pixels from edges.
const trim = function (raster) {
// Get bounds, trimming transparent pixels from edges.
const getHitBounds = function (raster) {
const width = raster.width;
const imageData = raster.getImageData(raster.bounds);
let top = 0;
@ -138,11 +138,16 @@ const trim = function (raster) {
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));
return new paper.Rectangle(left, top, right - left, bottom - top);
};
const trim = function (raster) {
return raster.getSubRaster(getHitBounds(raster));
};
export {
getBrushMark,
getHitBounds,
fillEllipse,
forEachLinePoint,
trim

View file

@ -26,21 +26,14 @@ const clearRaster = function () {
raster.parent = layer;
raster.guide = true;
raster.locked = true;
raster.position = paper.view.center;
raster.position = new paper.Point(ART_BOARD_WIDTH / 2, ART_BOARD_HEIGHT / 2);
};
const getRaster = function () {
const layer = _getLayer('isRasterLayer');
// Generate blank raster
if (layer.children.length === 0) {
const tmpCanvas = document.createElement('canvas');
tmpCanvas.width = ART_BOARD_WIDTH;
tmpCanvas.height = ART_BOARD_HEIGHT;
const raster = new paper.Raster(tmpCanvas);
raster.parent = layer;
raster.guide = true;
raster.locked = true;
raster.position = paper.view.center;
clearRaster();
}
return _getLayer('isRasterLayer').children[0];
};
@ -163,7 +156,7 @@ const _makeBackgroundGuideLayer = function () {
guideLayer.locked = true;
const vBackground = _makeBackgroundPaper(120, 90, '#E5E5E5');
vBackground.position = paper.view.center;
vBackground.position = new paper.Point(ART_BOARD_WIDTH / 2, ART_BOARD_HEIGHT / 2);
vBackground.scaling = new paper.Point(8, 8);
vBackground.guide = true;
vBackground.locked = true;
@ -171,21 +164,21 @@ const _makeBackgroundGuideLayer = function () {
const vLine = new paper.Path.Line(new paper.Point(0, -7), new paper.Point(0, 7));
vLine.strokeWidth = 2;
vLine.strokeColor = '#ccc';
vLine.position = paper.view.center;
vLine.position = new paper.Point(ART_BOARD_WIDTH / 2, ART_BOARD_HEIGHT / 2);
vLine.guide = true;
vLine.locked = true;
const hLine = new paper.Path.Line(new paper.Point(-7, 0), new paper.Point(7, 0));
hLine.strokeWidth = 2;
hLine.strokeColor = '#ccc';
hLine.position = paper.view.center;
hLine.position = new paper.Point(ART_BOARD_WIDTH / 2, ART_BOARD_HEIGHT / 2);
hLine.guide = true;
hLine.locked = true;
const circle = new paper.Shape.Circle(new paper.Point(0, 0), 5);
circle.strokeWidth = 2;
circle.strokeColor = '#ccc';
circle.position = paper.view.center;
circle.position = new paper.Point(ART_BOARD_WIDTH / 2, ART_BOARD_HEIGHT / 2);
circle.guide = true;
circle.locked = true;

View file

@ -4,6 +4,7 @@ import paper from '@scratch/paper';
import {hideGuideLayers, showGuideLayers, getRaster} from '../helper/layer';
import Formats from '../lib/format';
import {isVector, isBitmap} from '../lib/format';
import log from '../log/log';
/**
* Take an undo snapshot
@ -11,6 +12,9 @@ import {isVector, isBitmap} from '../lib/format';
* @param {Formats} format Either Formats.BITMAP or Formats.VECTOR
*/
const performSnapshot = function (dispatchPerformSnapshot, format) {
if (!format) {
log.error('Format must be specified.');
}
const guideLayers = hideGuideLayers();
dispatchPerformSnapshot({
json: paper.project.exportJSON({asString: false}),

View file

@ -1,10 +1,15 @@
import paper from '@scratch/paper';
import {getSelectedRootItems} from './selection';
// Vectors are imported and exported at SVG_ART_BOARD size.
// Once they are imported however, both SVGs and bitmaps are on
// canvases of ART_BOARD size.
const SVG_ART_BOARD_WIDTH = 480;
const SVG_ART_BOARD_HEIGHT = 360;
const ART_BOARD_WIDTH = 480 * 2;
const ART_BOARD_HEIGHT = 360 * 2;
const clampViewBounds = () => {
const _clampViewBounds = () => {
const {left, right, top, bottom} = paper.project.view.bounds;
if (left < 0) {
paper.project.view.scrollBy(new paper.Point(-left, 0));
@ -32,7 +37,7 @@ const zoomOnFixedPoint = (deltaZoom, fixedPoint) => {
.subtract(preZoomCenter);
view.zoom = newZoom;
view.translate(postZoomOffset.multiply(-1));
clampViewBounds();
_clampViewBounds();
};
// Zoom keeping the selection center (if any) fixed.
@ -57,18 +62,19 @@ const zoomOnSelection = deltaZoom => {
const resetZoom = () => {
paper.project.view.zoom = .5;
clampViewBounds();
_clampViewBounds();
};
const pan = (dx, dy) => {
paper.project.view.scrollBy(new paper.Point(dx, dy));
clampViewBounds();
_clampViewBounds();
};
export {
ART_BOARD_HEIGHT,
ART_BOARD_WIDTH,
clampViewBounds,
SVG_ART_BOARD_WIDTH,
SVG_ART_BOARD_HEIGHT,
pan,
resetZoom,
zoomOnSelection,

View file

@ -34,7 +34,8 @@ class Playground extends React.Component {
name: 'meow',
rotationCenterX: 20,
rotationCenterY: 400,
image: svgString
imageFormat: 'svg', // 'svg', 'png', or 'jpg'
image: svgString // svg string or data URI
};
}
handleUpdateName (name) {
@ -46,9 +47,16 @@ class Playground extends React.Component {
if (isVector) {
this.setState({image, rotationCenterX, rotationCenterY});
} else { // is Bitmap
const imageElement = new Image();
imageElement.src = image.toDataURL("image/png");
this.setState({imageElement, rotationCenterX, rotationCenterY});
// image parameter has type ImageData
// paint editor takes dataURI as input
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
context.putImageData(image, 0, 0);
this.setState({
image: canvas.toDataURL('image/png'),
rotationCenterX: rotationCenterX,
rotationCenterY: rotationCenterY
});
}
}
render () {