From df88d56d1b8750916d621388f67ce712194c868c Mon Sep 17 00:00:00 2001 From: DD Liu Date: Wed, 25 Jul 2018 19:07:35 -0400 Subject: [PATCH] Save bitmap selection (#569) --- src/containers/paint-editor.jsx | 86 ++++++++++++++++++++--------- src/helper/bit-tools/select-tool.js | 57 ++----------------- src/helper/bitmap.js | 71 ++++++++++++++++++++++++ src/helper/undo.js | 36 +++++++++--- 4 files changed, 164 insertions(+), 86 deletions(-) diff --git a/src/containers/paint-editor.jsx b/src/containers/paint-editor.jsx index 70b9ff2e..75bcd309 100644 --- a/src/containers/paint-editor.jsx +++ b/src/containers/paint-editor.jsx @@ -15,7 +15,7 @@ import {setTextEditTarget} from '../reducers/text-edit-target'; import {updateViewBounds} from '../reducers/view-bounds'; import {getRaster, hideGuideLayers, showGuideLayers} from '../helper/layer'; -import {convertToBitmap, convertToVector, getHitBounds} from '../helper/bitmap'; +import {commitSelectionToBitmap, convertToBitmap, convertToVector, 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'; @@ -39,6 +39,8 @@ class PaintEditor extends React.Component { super(props); bindAll(this, [ 'handleUpdateImage', + 'handleUpdateBitmap', + 'handleUpdateVector', 'handleUndo', 'handleRedo', 'handleSendBackward', @@ -186,36 +188,66 @@ class PaintEditor extends React.Component { actualFormat = BitmapModes[this.props.mode] ? Formats.BITMAP : Formats.VECTOR; } if (isBitmap(actualFormat)) { - const rect = getHitBounds(getRaster()); - this.props.onUpdateImage( - false /* isVector */, - getRaster().getImageData(rect), - (ART_BOARD_WIDTH / 2) - rect.x, - (ART_BOARD_HEIGHT / 2) - rect.y); + this.handleUpdateBitmap(skipSnapshot); } else if (isVector(actualFormat)) { - const guideLayers = hideGuideLayers(true /* includeRaster */); - - // Export at 0.5x - scaleWithStrokes(paper.project.activeLayer, .5, new paper.Point()); - const bounds = paper.project.activeLayer.bounds; - // @todo generate view box - this.props.onUpdateImage( - true /* isVector */, - paper.project.exportSVG({ - asString: true, - bounds: 'content', - matrix: new paper.Matrix().translate(-bounds.x, -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; - - showGuideLayers(guideLayers); + this.handleUpdateVector(skipSnapshot); } + } + handleUpdateBitmap (skipSnapshot) { + if (!getRaster().loaded) { + // In general, callers of updateImage should wait for getRaster().loaded = true before + // calling updateImage. + // However, this may happen if the user is rapidly undoing/redoing. In this case it's safe + // to skip the update. + log.warn('Bitmap layer should be loaded before calling updateImage.'); + return; + } + // Plaster the selection onto the raster layer before exporting, if there is a selection. + const plasteredRaster = getRaster().getSubRaster(getRaster().bounds); + plasteredRaster.remove(); // Don't insert + const selectedItems = getSelectedLeafItems(); + if (selectedItems.length === 1 && selectedItems[0] instanceof paper.Raster) { + if (!selectedItems[0].loaded || + (selectedItems[0].data && selectedItems[0].data.expanded && !selectedItems[0].data.expanded.loaded)) { + log.warn('Bitmap layer should be loaded before calling updateImage.'); + return; + } + commitSelectionToBitmap(selectedItems[0], plasteredRaster); + } + const rect = getHitBounds(plasteredRaster); + this.props.onUpdateImage( + false /* isVector */, + plasteredRaster.getImageData(rect), + (ART_BOARD_WIDTH / 2) - rect.x, + (ART_BOARD_HEIGHT / 2) - rect.y); if (!skipSnapshot) { - performSnapshot(this.props.undoSnapshot, actualFormat); + performSnapshot(this.props.undoSnapshot, Formats.BITMAP); + } + } + handleUpdateVector (skipSnapshot) { + const guideLayers = hideGuideLayers(true /* includeRaster */); + + // Export at 0.5x + scaleWithStrokes(paper.project.activeLayer, .5, new paper.Point()); + const bounds = paper.project.activeLayer.bounds; + // @todo generate view box + this.props.onUpdateImage( + true /* isVector */, + paper.project.exportSVG({ + asString: true, + bounds: 'content', + matrix: new paper.Matrix().translate(-bounds.x, -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; + + showGuideLayers(guideLayers); + + if (!skipSnapshot) { + performSnapshot(this.props.undoSnapshot, Formats.VECTOR); } } handleUndo () { diff --git a/src/helper/bit-tools/select-tool.js b/src/helper/bit-tools/select-tool.js index b6e5d0be..9fe6aa96 100644 --- a/src/helper/bit-tools/select-tool.js +++ b/src/helper/bit-tools/select-tool.js @@ -1,8 +1,8 @@ import paper from '@scratch/paper'; import Modes from '../../lib/modes'; -import {createCanvas, getRaster} from '../layer'; -import {fillRect, scaleBitmap} from '../bitmap'; +import {getRaster} from '../layer'; +import {commitSelectionToBitmap} from '../bitmap'; import BoundingBoxTool from '../selection-tools/bounding-box-tool'; import NudgeTool from '../selection-tools/nudge-tool'; @@ -117,57 +117,10 @@ class SelectTool extends paper.Tool { commitSelection () { if (!this.selection || !this.selection.parent) return; - this.maybeApplyScaleToCanvas(this.selection); - this.commitArbitraryTransformation(this.selection); - this.onUpdateImage(); - } - maybeApplyScaleToCanvas (item) { - if (!item.matrix.isInvertible()) { - item.remove(); - return; - } - - // context.drawImage will anti-alias the image if both width and height are reduced. - // However, it will preserve pixel colors if only one or the other is reduced, and - // imageSmoothingEnabled is set to false. Therefore, we can avoid aliasing by scaling - // down images in a 2 step process. - const decomposed = item.matrix.decompose(); // Decomposition order: translate, rotate, scale, skew - if (Math.abs(decomposed.scaling.x) < 1 && Math.abs(decomposed.scaling.y) < 1 && - decomposed.scaling.x !== 0 && decomposed.scaling.y !== 0) { - item.canvas = scaleBitmap(item.canvas, decomposed.scaling); - if (item.data && item.data.expanded) { - item.data.expanded.canvas = scaleBitmap(item.data.expanded.canvas, decomposed.scaling); - } - // Remove the scale from the item's matrix - item.matrix.append( - new paper.Matrix().scale(new paper.Point(1 / decomposed.scaling.x, 1 / decomposed.scaling.y))); - } - } - commitArbitraryTransformation (item) { - // Create a canvas to perform masking - const tmpCanvas = createCanvas(); - const context = tmpCanvas.getContext('2d'); - // Draw mask - const rect = new paper.Shape.Rectangle(new paper.Point(), item.size); - rect.matrix = item.matrix; - fillRect(rect, context); - rect.remove(); - context.globalCompositeOperation = 'source-in'; - - // Draw image onto mask - const m = item.matrix; - context.transform(m.a, m.b, m.c, m.d, m.tx, m.ty); - let canvas = item.canvas; - if (item.data && item.data.expanded) { - canvas = item.data.expanded.canvas; - } - context.transform(1, 0, 0, 1, -canvas.width / 2, -canvas.height / 2); - context.drawImage(canvas, 0, 0); - - // Draw temp canvas onto raster layer - getRaster().drawImage(tmpCanvas, new paper.Point()); - item.remove(); + commitSelectionToBitmap(this.selection, getRaster()); + this.selection.remove(); this.selection = null; + this.onUpdateImage(); } deactivateTool () { this.commitSelection(); diff --git a/src/helper/bitmap.js b/src/helper/bitmap.js index d1c79d99..0c0cd82a 100644 --- a/src/helper/bitmap.js +++ b/src/helper/bitmap.js @@ -653,7 +653,78 @@ const scaleBitmap = function (canvas, scale) { return tmpCanvas; }; +/** + * Given a raster, take the scale on the transform and apply it to the raster's canvas, then remove + * the scale from the item's transform matrix. Do this only if scale.x or scale.y is less than 1. + * @param {paper.Raster} item raster to change + */ +const maybeApplyScaleToCanvas_ = function (item) { + if (!item.matrix.isInvertible()) { + item.remove(); + return; + } + + // context.drawImage will anti-alias the image if both width and height are reduced. + // However, it will preserve pixel colors if only one or the other is reduced, and + // imageSmoothingEnabled is set to false. Therefore, we can avoid aliasing by scaling + // down images in a 2 step process. + const decomposed = item.matrix.decompose(); // Decomposition order: translate, rotate, scale, skew + if (Math.abs(decomposed.scaling.x) < 1 && Math.abs(decomposed.scaling.y) < 1 && + decomposed.scaling.x !== 0 && decomposed.scaling.y !== 0) { + item.canvas = scaleBitmap(item.canvas, decomposed.scaling); + if (item.data && item.data.expanded) { + item.data.expanded.canvas = scaleBitmap(item.data.expanded.canvas, decomposed.scaling); + } + // Remove the scale from the item's matrix + item.matrix.append( + new paper.Matrix().scale(new paper.Point(1 / decomposed.scaling.x, 1 / decomposed.scaling.y))); + } +}; + +/** + * Given a raster, apply its transformation matrix to its canvas. Call maybeApplyScaleToCanvas_ first + * to avoid introducing anti-aliasing to scaled-down rasters. + * @param {paper.Raster} item raster to resolve transform of + * @param {paper.Raster} destination raster to draw selection to + */ +const commitArbitraryTransformation_ = function (item, destination) { + // Create a canvas to perform masking + const tmpCanvas = createCanvas(); + const context = tmpCanvas.getContext('2d'); + // Draw mask + const rect = new paper.Shape.Rectangle(new paper.Point(), item.size); + rect.matrix = item.matrix; + fillRect(rect, context); + rect.remove(); + context.globalCompositeOperation = 'source-in'; + + // Draw image onto mask + const m = item.matrix; + context.transform(m.a, m.b, m.c, m.d, m.tx, m.ty); + let canvas = item.canvas; + if (item.data && item.data.expanded) { + canvas = item.data.expanded.canvas; + } + context.transform(1, 0, 0, 1, -canvas.width / 2, -canvas.height / 2); + context.drawImage(canvas, 0, 0); + + // Draw temp canvas onto raster layer + destination.drawImage(tmpCanvas, new paper.Point()); +}; + +/** + * Given a raster item, take its transform matrix and apply it to its canvas. Try to avoid + * introducing anti-aliasing. + * @param {paper.Raster} selection raster to resolve transform of + * @param {paper.Raster} bitmap raster to draw selection to + */ +const commitSelectionToBitmap = function (selection, bitmap) { + maybeApplyScaleToCanvas_(selection); + commitArbitraryTransformation_(selection, bitmap); +}; + export { + commitSelectionToBitmap, convertToBitmap, convertToVector, fillRect, diff --git a/src/helper/undo.js b/src/helper/undo.js index 671822cb..84864656 100644 --- a/src/helper/undo.js +++ b/src/helper/undo.js @@ -2,6 +2,7 @@ // modifed from https://github.com/memononen/stylii import paper from '@scratch/paper'; import {hideGuideLayers, showGuideLayers, getRaster} from '../helper/layer'; +import {getSelectedLeafItems} from '../helper/selection'; import Formats from '../lib/format'; import {isVector, isBitmap} from '../lib/format'; import log from '../log/log'; @@ -23,7 +24,7 @@ const performSnapshot = function (dispatchPerformSnapshot, format) { showGuideLayers(guideLayers); }; -const _restore = function (entry, setSelectedItems, onUpdateImage) { +const _restore = function (entry, setSelectedItems, onUpdateImage, isBitmapMode) { for (let i = paper.project.layers.length - 1; i >= 0; i--) { const layer = paper.project.layers[i]; if (!layer.data.isBackgroundGuideLayer) { @@ -32,20 +33,41 @@ const _restore = function (entry, setSelectedItems, onUpdateImage) { } } paper.project.importJSON(entry.json); - setSelectedItems(); - getRaster().onLoad = function () { + + // Ensure that all rasters are loaded before updating storage with new image data. + const rastersThatNeedToLoad = []; + const onLoad = () => { + if (!getRaster().loaded) return; + for (const raster of rastersThatNeedToLoad) { + if (!raster.loaded) return; + } onUpdateImage(true /* skipSnapshot */); }; - if (getRaster().loaded) { - getRaster().onLoad(); + + // Bitmap mode should have at most 1 selected item + if (isBitmapMode) { + const selectedItems = getSelectedLeafItems(); + if (selectedItems.length === 1 && selectedItems[0] instanceof paper.Raster) { + rastersThatNeedToLoad.push(selectedItems[0]); + if (selectedItems[0].data && selectedItems[0].data.expanded instanceof paper.Raster) { + rastersThatNeedToLoad.push(selectedItems[0].data.expanded); + } + } + } + + getRaster().onLoad = onLoad; + if (getRaster().loaded) getRaster().onLoad(); + for (const raster of rastersThatNeedToLoad) { + raster.onLoad = onLoad; + if (raster.loaded) raster.onLoad(); } }; const performUndo = function (undoState, dispatchPerformUndo, setSelectedItems, onUpdateImage) { if (undoState.pointer > 0) { const state = undoState.stack[undoState.pointer - 1]; - _restore(state, setSelectedItems, onUpdateImage); + _restore(state, setSelectedItems, onUpdateImage, isBitmap(state.paintEditorFormat)); const format = isVector(state.paintEditorFormat) ? Formats.VECTOR_SKIP_CONVERT : isBitmap(state.paintEditorFormat) ? Formats.BITMAP_SKIP_CONVERT : null; dispatchPerformUndo(format); @@ -56,7 +78,7 @@ const performUndo = function (undoState, dispatchPerformUndo, setSelectedItems, const performRedo = function (undoState, dispatchPerformRedo, setSelectedItems, onUpdateImage) { if (undoState.pointer >= 0 && undoState.pointer < undoState.stack.length - 1) { const state = undoState.stack[undoState.pointer + 1]; - _restore(state, setSelectedItems, onUpdateImage); + _restore(state, setSelectedItems, onUpdateImage, isBitmap(state.paintEditorFormat)); const format = isVector(state.paintEditorFormat) ? Formats.VECTOR_SKIP_CONVERT : isBitmap(state.paintEditorFormat) ? Formats.BITMAP_SKIP_CONVERT : null; dispatchPerformRedo(format);