mirror of
https://github.com/igorski/bitmappery.git
synced 2026-06-17 03:34:56 +02:00
Resolved issue with dragging of rotated layer content
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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" );
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user