From 2c4e1759d43ad1f578bf34428f2c47aaa0f370a9 Mon Sep 17 00:00:00 2001 From: Igor Zinken <730069+igorski@users.noreply.github.com> Date: Tue, 4 Mar 2025 10:27:29 +0100 Subject: [PATCH] Address issue where stepping through state history for mask drawing is not restoring state correctly --- src/rendering/canvas-elements/layer-sprite.ts | 20 ++++++----- src/rendering/masking.ts | 36 +++++++++++++++++++ src/services/render-service.ts | 16 +++------ 3 files changed, 51 insertions(+), 21 deletions(-) create mode 100644 src/rendering/masking.ts diff --git a/src/rendering/canvas-elements/layer-sprite.ts b/src/rendering/canvas-elements/layer-sprite.ts index aa9aefe..c81fafd 100644 --- a/src/rendering/canvas-elements/layer-sprite.ts +++ b/src/rendering/canvas-elements/layer-sprite.ts @@ -85,7 +85,7 @@ class LayerSprite extends ZoomableSprite { protected _invertSelection: boolean; protected _toolType: ToolTypes; protected _orgSourceToStore: string; - protected _pendingPaintState: number; + protected _pendingPaintState: number; // ReturnType; protected _rafFx: boolean; constructor( layer: Layer ) { @@ -391,14 +391,14 @@ class LayerSprite extends ZoomableSprite { * not delay to history state UI from updating more than necessary. */ preparePendingPaintState(): void { - canvasToBlob( this.layer.source ).then( blob => { + canvasToBlob( this.getPaintSource() ).then( blob => { this._orgSourceToStore = blobToResource( blob ); }); this.debouncePaintStore(); } debouncePaintStore( timeout: number = 5000 ): void { - this._pendingPaintState = window.setTimeout( this.storePaintState.bind( this ), timeout ) as unknown as number; + this._pendingPaintState = window.setTimeout( this.storePaintState.bind( this ), timeout ); } getPaintSource(): HTMLCanvasElement { @@ -430,14 +430,15 @@ class LayerSprite extends ZoomableSprite { this._orgSourceToStore = null; - const blob = await canvasToBlob( layer.source ); + const isMask = this.isMaskable(); + const blob = await canvasToBlob( this.getPaintSource() ); const newState = blobToResource( blob ); enqueueState( `spritePaint_${layer.id}`, { undo(): void { - restorePaintFromHistory( layer, orgState ); + restorePaintFromHistory( layer, orgState, isMask ); }, redo(): void { - restorePaintFromHistory( layer, newState); + restorePaintFromHistory( layer, newState, isMask ); }, resources: [ orgState, newState ], }); @@ -781,9 +782,10 @@ function positionSpriteFromHistory( layer: Layer, x: number, y: number ): void { } } -function restorePaintFromHistory( layer: Layer, state: string ): void { - const ctx = layer.source.getContext( "2d" ); - ctx.clearRect( 0, 0, layer.source.width, layer.source.height ); +function restorePaintFromHistory( layer: Layer, state: string, isMask: boolean ): void { + const source = isMask ? layer.mask : layer.source; + const ctx = source.getContext( "2d" ) as CanvasRenderingContext2D; + ctx.clearRect( 0, 0, source.width, source.height ); const image = new Image(); image.onload = () => { ctx.drawImage( image, 0, 0 ); diff --git a/src/rendering/masking.ts b/src/rendering/masking.ts new file mode 100644 index 0000000..0119fea --- /dev/null +++ b/src/rendering/masking.ts @@ -0,0 +1,36 @@ +/** + * The MIT License (MIT) + * + * Igor Zinken 2021-2025 - https://www.igorski.nl + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER + * IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN + * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +export const maskImage = ( + destinationContext: CanvasRenderingContext2D, image: HTMLCanvasElement, mask: HTMLCanvasElement, + width: number, height: number, maskOffsetX = 0, maskOffsetY = 0 +): void => { + destinationContext.clearRect( 0, 0, width, height ); + destinationContext.drawImage( image, 0, 0 ); + + destinationContext.save(); + + destinationContext.globalCompositeOperation = "destination-out"; + destinationContext.drawImage( mask, maskOffsetX, maskOffsetY ); + + destinationContext.restore(); +}; \ No newline at end of file diff --git a/src/services/render-service.ts b/src/services/render-service.ts index 8d9766e..30a1b03 100644 --- a/src/services/render-service.ts +++ b/src/services/render-service.ts @@ -31,6 +31,7 @@ import { replaceLayerSource } from "@/utils/layer-util"; import { clone } from "@/utils/object-util"; import { getLayerCache, setLayerCache } from "@/rendering/cache/bitmap-cache"; import type { RenderCache } from "@/rendering/cache/bitmap-cache"; +import { maskImage } from "@/rendering/masking"; import { renderMultiLineText } from "@/rendering/text"; import { loadGoogleFont } from "@/services/font-service"; import FilterWorker from "@/workers/filter.worker?worker"; @@ -132,7 +133,7 @@ export const renderEffectsForLayer = async ( layer: Layer, useCaching = true ): if ( applyMask ) { //console.info( "apply mask" ); - await renderMask( layer, ctx, applyFilter ? cloneCanvas( cvs ) : layer.source, width, height ); + renderMask( layer, ctx, applyFilter ? cloneCanvas( cvs ) : layer.source, width, height ); } // step 4. update cache and on-screen canvas contents @@ -222,20 +223,11 @@ const renderText = async ( layer: Layer ): Promise => { return cvs; }; -const renderMask = async ( layer: Layer, ctx: CanvasRenderingContext2D, sourceBitmap: HTMLCanvasElement, - width: number, height: number ): Promise => { +const renderMask = ( layer: Layer, ctx: CanvasRenderingContext2D, sourceBitmap: HTMLCanvasElement, width: number, height: number ): void => { if ( !layer.mask ) { return; } - ctx.clearRect( 0, 0, width, height ); - ctx.drawImage( sourceBitmap, 0, 0 ); - - ctx.save(); - - // commenting out the next line allows you to debug by seeing the mask - ctx.globalCompositeOperation = "destination-out"; - ctx.drawImage( layer.mask, layer.maskX, layer.maskY ); - ctx.restore(); + maskImage( ctx, sourceBitmap, layer.mask, width, height, layer.maskX, layer.maskY ); }; function getJobFromQueue( jobId: number ): RenderJob | undefined {