diff --git a/README.md b/README.md index a2ea91e..cdf9ed4 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/src/components/options-panel/components/file-selector/file-selector.vue b/src/components/options-panel/components/file-selector/file-selector.vue index d42f4bd..f881fdc 100644 --- a/src/components/options-panel/components/file-selector/file-selector.vue +++ b/src/components/options-panel/components/file-selector/file-selector.vue @@ -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" ); }, } }; diff --git a/src/components/ui/zcanvas/layer-sprite.js b/src/components/ui/zcanvas/layer-sprite.js index 3a7da6b..0f1744f 100644 --- a/src/components/ui/zcanvas/layer-sprite.js +++ b/src/components/ui/zcanvas/layer-sprite.js @@ -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; diff --git a/src/services/render-service.js b/src/services/render-service.js index 267674c..5a25a9d 100644 --- a/src/services/render-service.js +++ b/src/services/render-service.js @@ -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(); } diff --git a/src/store/modules/document-module.js b/src/store/modules/document-module.js index 234fe0b..feff6f1 100644 --- a/src/store/modules/document-module.js +++ b/src/store/modules/document-module.js @@ -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(); } }, }, diff --git a/tests/unit/store/modules/document-module.spec.js b/tests/unit/store/modules/document-module.spec.js index b8ff9f2..e9c0ad4 100644 --- a/tests/unit/store/modules/document-module.spec.js +++ b/tests/unit/store/modules/document-module.spec.js @@ -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(); }); }); });