Resolved issue with dragging of rotated layer content

This commit is contained in:
Igor Zinken
2020-12-27 13:12:18 +01:00
parent e1a782ac72
commit b9851c4756
6 changed files with 83 additions and 83 deletions

View File

@@ -55,7 +55,6 @@ npm run lint
# TODO / Roadmap
* Fix bug that occurs when dragging a rotated layer
* Copying selections on rotated content doesn't work
* Copying selection should preserve original scale
* Fill selection with color (make color not unique to brush, but generic tool prop)

View File

@@ -57,7 +57,6 @@ export default {
const start = Date.now();
await loadImageFiles( files, this.addLoadedFile.bind( this ));
const elapsed = Date.now() - start;
console.warn( "Total time for load: " + ( elapsed / 1000 ) + " seconds" );
},
}
};

View File

@@ -24,7 +24,8 @@ import Vue from "vue";
import { sprite } from "zcanvas";
import { createCanvas, resizeImage, globalToLocal } from "@/utils/canvas-util";
import { LAYER_GRAPHIC, LAYER_MASK } from "@/definitions/layer-types";
import { isPointInRange } from "@/utils/image-math";
import { isPointInRange, translatePointerRotation } from "@/utils/image-math";
import { renderEffectsForLayer } from "@/services/render-service";
import ToolTypes from "@/definitions/tool-types";
/**
@@ -34,19 +35,16 @@ import ToolTypes from "@/definitions/tool-types";
*/
class LayerSprite extends sprite {
constructor( layer ) {
const { bitmap, x, y, width, height } = layer;
super({ bitmap, x, y, width, height } ); // zCanvas inheritance
this.layer = layer; // the Layer this Sprite will be rendering
if ( layer.type === LAYER_GRAPHIC && !layer.source ) {
// create a Bitmap on which this layer will render its drawable content.
// assign this Bitmap to the layer
// create a Canvas on which this layer will render its drawable content.
const { cvs } = createCanvas( layer.width, layer.height );
layer.source = cvs;
}
let { x, y, width, height } = layer;
// zCanvas inheritance
super({ bitmap: layer.bitmap || layer.source, x, y, width, height } );
// Layer this sprite is rendering
this.layer = layer;
// create brush (always as all layers can be maskable)
const brushCanvas = createCanvas();
@@ -57,8 +55,16 @@ class LayerSprite extends sprite {
this._pointerY = 0;
this.cacheBrush( this.canvas?.store.getters.activeColor || "rgba(255,0,0,1)" );
this.cacheMask();
this.setActionTarget();
if ( layer.source instanceof Image ) {
const handler = () => {
this.cacheEffects();
layer.source.removeEventListener( "load", handler );
}
layer.source.addEventListener( "load", handler );
}
this.cacheEffects();
}
setActionTarget( target = "source" ) {
@@ -110,13 +116,15 @@ class LayerSprite extends sprite {
this._halfRadius = radius / 2;
}
cacheMask() {
if ( !!this.layer.mask ) {
this._maskCanvas = createCanvas( this.layer.width, this.layer.height ).cvs;
this._cacheMask = true; // requests initial rendering of masked content
} else {
this._maskCanvas = null;
cacheEffects() {
if ( this._rafFx ) {
return; // debounced to only occur once before next render cycle
}
this._rafFx = true;
requestAnimationFrame(() => {
renderEffectsForLayer( this.layer );
this._rafFx = false;
});
}
handleActiveTool( tool, activeLayer ) {
@@ -179,7 +187,7 @@ class LayerSprite extends sprite {
this.layer.mask = await resizeImage(
this.layer.mask, this._bounds.width, this._bounds.height, width, height
);
this.cacheMask();
this.cacheEffects();
}
this.setBounds( this.getX() * ratioX, this.getY() * ratioY, width, height );
this.invalidate();
@@ -195,7 +203,7 @@ class LayerSprite extends sprite {
/* the following override zCanvas.sprite */
handleMove( x, y ) {
// store reference to current pointer position
// store reference to current pointer position (relative to canvas)
this._pointerX = x;
this._pointerY = y;
@@ -204,7 +212,6 @@ class LayerSprite extends sprite {
if ( this.actionTarget === "mask" ) {
this.layer.maskX = this._dragStartOffset.x + ( x - this._dragStartEventCoordinates.x );
this.layer.maskY = this._dragStartOffset.y + ( y - this._dragStartEventCoordinates.y );
this._cacheMask = true;
} else if ( !this._isSelectMode ) {
this.layer.x = x;
this.layer.y = y;
@@ -214,6 +221,11 @@ class LayerSprite extends sprite {
// brush tool active (either draws/erasers onto IMAGE_GRAPHIC layer source
// or on the mask bitmap)
if ( this._applyBrush ) {
// translate pointer to rotated space, when layer is rotated
const rotation = this.layer.effects.rotation;
if (( rotation % 360 ) !== 0 ) {
({ x, y } = translatePointerRotation( x, y, this._bounds.left + this._bounds.width / 2, this._bounds.top + this._bounds.height / 2, this.layer.effects.rotation ));
}
const drawOnMask = this.isMaskable();
const isEraser = this._brushType === ToolTypes.ERASER;
// get the drawing context
@@ -223,14 +235,11 @@ class LayerSprite extends sprite {
ctx.globalCompositeOperation = "destination-out";
}
// note we draw directly onto the layer bitmaps, making this permanent
ctx.drawImage( this._brushCvs, ( x - this.getX() ) - this._radius, y - this.getY() - this._radius );
ctx.drawImage( this._brushCvs, x - this._radius, y - this._radius );
if ( isEraser ) {
ctx.restore();
}
// invalidate cached mask canvas contents (draw() method will render these)
if ( drawOnMask ) {
this._cacheMask = true;
}
this.cacheEffects(); // sync mask and source changes with sprite Bitmap
}
}
@@ -257,36 +266,9 @@ class LayerSprite extends sprite {
}
}
invalidate() {
this._cacheMask = true;
super.invalidate();
}
draw( documentContext ) {
if ( !this.isMaskable() ) {
// use base draw() logic when no mask is set
super.draw( documentContext );
} else if ( this._maskCanvas ) {
const { left, top, width, height } = this._bounds;
// render masked contents into mask canvas
if ( this._cacheMask ) {
const ctx = this._maskCanvas.getContext( "2d" );
ctx.save();
ctx.drawImage( this._bitmap, 0, 0 );
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage( this.layer.mask, this.layer.maskX, this.layer.maskY );
ctx.restore();
this._cacheMask = false;
}
// render cached mask canvas onto document context
documentContext.drawImage(
this._maskCanvas,
( .5 + left ) << 0,
( .5 + top ) << 0,
( .5 + width ) << 0,
( .5 + height ) << 0
);
}
super.draw( documentContext ); // renders bitmap
// render brush outline at pointer position
if ( this._isBrushMode ) {
documentContext.save();
@@ -323,9 +305,8 @@ class LayerSprite extends sprite {
dispose() {
super.dispose();
this._bitmap = null;
this._maskCanvas = null;
this._brushCvs = null;
this._bitmap = null;
this._brushCvs = null;
}
}
export default LayerSprite;

View File

@@ -22,7 +22,7 @@
*/
import { getSpriteForLayer } from "@/factories/sprite-factory";
import { createCanvas } from "@/utils/canvas-util";
import { getRotationCenter } from "@/utils/image-math";
import { getRotatedSize, getRotationCenter } from "@/utils/image-math";
const queue = [];
@@ -34,7 +34,8 @@ export const renderEffectsForLayer = async layer => {
return;
}
const { width, height } = layer;
// if source is rotated, calculate the width and height for the current rotation
const { width, height } = getRotatedSize( layer, effects.rotation );
let cvs;
if ( sprite._bitmap instanceof HTMLCanvasElement ) {
cvs = sprite._bitmap;
@@ -46,30 +47,49 @@ export const renderEffectsForLayer = async layer => {
const ctx = cvs.getContext( "2d" );
if ( hasEffects( layer )) {
await renderTransformations( ctx, layer.source, width, height, effects.rotation );
await renderTransformedSource( layer, ctx, layer.source, width, height, effects.rotation );
} else {
ctx.drawImage( layer.source, 0, 0 );
}
// update on-screen canvas contents
sprite.setBitmap( cvs, width, height );
sprite.cacheMask();
sprite.invalidate();
};
/* internal methods */
const hasEffects = ({ effects }) => {
return effects.rotation !== 0;
const hasEffects = ( layer ) => {
const { effects } = layer;
return !!layer.mask || effects.rotation !== 0;
};
const renderTransformations = async ( ctx, sourceBitmap, width, height, rotation ) => {
const { x, y } = getRotationCenter({ left: 0, top: 0, width, height });
const renderTransformedSource = async ( layer, ctx, sourceBitmap, width, height, rotation ) => {
const rotate = ( rotation % 360 ) !== 0;
let targetX = 0, targetY = 0;
if ( rotate ) {
const { x, y } = getRotationCenter({ left: 0, top: 0, width, height });
ctx.save();
ctx.translate( x, y );
ctx.rotate( rotation );
ctx.translate( -x, -y );
targetX = x - layer.width * .5;
targetY = y - layer.height * .5;
}
ctx.drawImage( sourceBitmap, targetX, targetY );
await renderMask( layer, ctx, targetX, targetY );
if ( rotate ) {
ctx.restore();
}
}
const renderMask = async( layer, ctx, tX = 0, tY = 0 ) => {
if ( !layer.mask ) {
return;
}
ctx.save();
ctx.translate( x, y );
ctx.rotate( rotation );
ctx.translate( -x, -y );
ctx.drawImage( sourceBitmap, 0, 0 );
ctx.translate( tX, tY );
ctx.globalCompositeOperation = "destination-in";
ctx.drawImage( layer.mask, layer.maskX, layer.maskY );
ctx.restore();
}

View File

@@ -25,7 +25,6 @@ import Vue from "vue";
import DocumentFactory from "@/factories/document-factory";
import LayerFactory from "@/factories/layer-factory";
import { flushLayerSprites, runSpriteFn, getSpriteForLayer } from "@/factories/sprite-factory";
import { renderEffectsForLayer } from "@/services/render-service";
export default {
state: {
@@ -110,8 +109,7 @@ export default {
const sprite = getSpriteForLayer( layer );
if ( sprite ) {
sprite.layer = layer;
sprite.cacheMask();
sprite.canvas?.invalidate();
sprite.cacheEffects();
}
},
updateLayerEffects( state, { index, effects = {} }) {
@@ -124,7 +122,7 @@ export default {
const sprite = getSpriteForLayer( layer );
if ( sprite ) {
sprite.layer = layer;
renderEffectsForLayer( layer );
sprite.cacheEffects();
}
},
},

View File

@@ -12,9 +12,6 @@ jest.mock( "@/factories/sprite-factory", () => ({
jest.mock( "@/factories/layer-factory", () => ({
create: (...args) => mockUpdateFn?.( "create", ...args ),
}));
jest.mock( "@/services/render-service", () => ({
renderEffectsForLayer: (...args) => mockUpdateFn?.( "renderEffectsForLayer", ...args ),
}));
describe( "Vuex document module", () => {
describe( "getters", () => {
@@ -274,20 +271,26 @@ describe( "Vuex document module", () => {
height: 150,
type: LAYER_IMAGE
};
const mockSprite = { src: "bitmap", cacheEffects: jest.fn() };
mockUpdateFn = jest.fn( fn => {
if ( fn === "getSpriteForLayer" ) return mockSprite;
return true;
});
mutations.updateLayer( state, { index, opts });
expect( state.documents[ 0 ].layers[ index ] ).toEqual({
...layer2,
...opts
});
expect( mockUpdateFn ).toHaveBeenCalledWith( "getSpriteForLayer", state.documents[ 0 ].layers[ index ] );
expect( mockSprite.cacheEffects ).toHaveBeenCalled();
});
it( "should be able to update the effects of a specific layer within the active Document", () => {
const index = 0;
const effects = {
rotation: 1.6
};
const effects = { rotation: 1.6 };
const mockSprite = { src: "bitmap", cacheEffects: jest.fn() };
mockUpdateFn = jest.fn( fn => {
if ( fn === "getSpriteForLayer" ) return { src: "bitmap" };
if ( fn === "getSpriteForLayer" ) return mockSprite;
return true;
});
mutations.updateLayerEffects( state, { index, effects });
@@ -296,7 +299,7 @@ describe( "Vuex document module", () => {
effects,
});
expect( mockUpdateFn ).toHaveBeenCalledWith( "getSpriteForLayer", state.documents[ 0 ].layers[ index ] );
expect( mockUpdateFn ).toHaveBeenCalledWith( "renderEffectsForLayer", state.documents[ 0 ].layers[ index ] );
expect( mockSprite.cacheEffects ).toHaveBeenCalled();
});
});
});