Fixed issue with document resizing, sprite functions can now be run for specific documents, added unit tets for sprite factory

This commit is contained in:
Igor Zinken
2020-12-22 12:13:18 +01:00
parent 98603d6c20
commit c8189b9b2e
10 changed files with 185 additions and 43 deletions

View File

@@ -50,25 +50,19 @@ npm run lint
# TODO / Roadmap
* Something weird with best fit only fitting when window resize fires (also see weird non-cleared canvas areas)
* Mask cache invalidation on document resize/switch
* Layer and mask coordinates are not saved when saving a document?
* Layer bitmaps and masks must not be stored as Vue observables
* Layer and mask coordinates are not saved when saving / switching document?
* Only draw visible content (hope to improve zoomed in performance)
* LayerSprite should show outline when brush is selected
* Canvas clearRect() is not doing full width and height ? (might be related to drawable layer click color problem)
* Document resizing doesn't rescale sprites properly?
* Layer view in options-panel: allow naming, repositioning, toggle visibility, opacity
* Canvas util : store transparency of images
* Restored base64 images should be treated as binary once more (see layer-factory)
* scale logic should move from zoomable-canvas into zCanvas (as handleInteraction needs to transform offsets by zoom ratio, see LayerSprite!)
* Zoom set original size isn't that accurate (check also on mobile views), needs calculateMaxScaling ?
* adjust scaling (on widescreen images scale in the width, rather than go for full height and zoomed out mode)
* Image position must be made persistent (now isn't on document switch)
* Implement selections
* Unload Blobs when images are no longer used in document (see sprite-factory disposeSprite, keep instance count of usages)
* Load/save documents from/to Dropbox
* Use hand cursor when draggable
* Text editing using Google fonts!
* Use paint brush cursor when painting
* Add tools for layer rotation and scaling
* Implement layer scale
* Implement clone brush

View File

@@ -48,7 +48,7 @@ import { MAX_ZOOM, calculateMaxScaling } from "@/definitions/tool-types";
import { scaleToRatio, scaleValue } from "@/utils/image-math";
import {
getCanvasInstance, setCanvasInstance,
createSpriteForLayer, runSpriteFn, flushLayerSprites, flushCache,
createSpriteForLayer, flushLayerSprites, flushCache,
} from "@/factories/sprite-factory";
/* internal non-reactive properties */

View File

@@ -31,7 +31,7 @@
<label v-t="'width'"></label>
<input
ref="first"
v-model="width"
v-model.number="width"
type="number"
name="width"
/>
@@ -47,7 +47,7 @@
<div class="wrapper input">
<label v-t="'height'"></label>
<input
v-model="height"
v-model.number="height"
type="number"
name="height"
/>

View File

@@ -79,7 +79,7 @@ import Slider from "@/components/ui/slider/slider";
import { mapSelectOptions } from "@/utils/search-select-util";
import { getSpriteForLayer, getCanvasInstance } from "@/factories/sprite-factory";
import { EXPORTABLE_FILE_TYPES, typeToExt, isCompressableFileType } from "@/definitions/image-types";
import { createCanvas, resizeImage } from "@/utils/canvas-util";
import { createCanvas, resizeToBase64 } from "@/utils/canvas-util";
import { saveBlobAsFile } from "@/utils/file-util";
import messages from "./messages.json";
@@ -127,7 +127,7 @@ export default {
// zCanvas magnifies content by the pixel ratio for a crisper result, downscale
// to actual dimensions of the document
const resizedImage = await resizeImage(
const resizedImage = await resizeToBase64(
base64,
width * ( window.devicePixelRatio || 1 ),
height * ( window.devicePixelRatio || 1 ),

View File

@@ -20,8 +20,8 @@
* 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.
*/
import { sprite } from "zcanvas";
import { createCanvas } from "@/utils/canvas-util";
import { sprite } from "zcanvas";
import { createCanvas, resizeImage } from "@/utils/canvas-util";
import { LAYER_GRAPHIC, LAYER_MASK } from "@/definitions/layer-types";
import ToolTypes from "@/definitions/tool-types";
@@ -130,6 +130,24 @@ class LayerSprite extends sprite {
}
}
async resize( width, height ) {
const ratio = width / this.width;
if ( this.layer.bitmap ) {
this.layer.bitmap = await resizeImage(
this.layer.bitmap, this._bounds.width, this._bounds.height, width, height
);
}
if ( this.layer.mask ) {
this.layer.mask = await resizeImage(
this.layer.mask, this._bounds.width, this._bounds.height, width, height
);
this.cacheMask();
}
this.setBounds( this.getX() * ratio, this.getY() * ratio, width, height );
this.invalidate();
}
// cheap way to hook into zCanvas.handleMove() handler keep following the cursor in draw()
forceDrag() {
this.isDragging = true;
@@ -178,6 +196,11 @@ class LayerSprite extends sprite {
}
}
invalidate() {
this._cacheMask = true;
super.invalidate();
}
draw( documentContext ) {
if ( !this.isMaskable() ) {
// use base draw() logic when no mask is set

View File

@@ -23,7 +23,7 @@
import LayerSprite from "@/components/ui/zcanvas/layer-sprite";
import { LAYER_IMAGE, LAYER_GRAPHIC, LAYER_MASK } from "@/definitions/layer-types";
let zCanvasInstance; // a non-Vue observable zCanvas instance
let zCanvasInstance = null; // a non-Vue observable zCanvas instance
export const getCanvasInstance = () => zCanvasInstance;
export const setCanvasInstance = zCanvas => zCanvasInstance = zCanvas;
@@ -36,9 +36,15 @@ const spriteCache = new Map();
/**
* Runs given fn on each Sprite in the cache
* You can also pass in the active document to only run operations on
* sprites rendering the layer of the current document
*/
export const runSpriteFn = fn => {
spriteCache.forEach( fn );
export const runSpriteFn = ( fn, optDocument ) => {
spriteCache.forEach(( sprite ) => {
if ( !optDocument || optDocument.layers.includes( sprite.layer )) {
fn( sprite );
}
});
};
/**
@@ -57,7 +63,7 @@ export const hasSpriteForLayer = ({ id }) => {
return spriteCache.has( id );
};
export const getSpriteForLayer = ({ id }) => spriteCache.get( id );
export const getSpriteForLayer = ({ id }) => spriteCache.get( id ) || null;
/**
* Clears the entire cache and disposes all Sprites.
@@ -92,7 +98,6 @@ export const createSpriteForLayer = ( zCanvasInstance, layer, isInteractive = fa
/* internal methods */
function disposeSprite( sprite ) {
console.warn( "disposing sprite" );
sprite?.dispose();
// TODO: also free associated bitmap ?
}

View File

@@ -44,19 +44,17 @@ export default {
mutations: {
setActiveDocument( state, index ) {
state.activeIndex = index;
runSpriteFn( sprite => sprite.invalidate(), state.documents[ index ] );
},
setActiveDocumentSize( state, { width, height }) {
const document = state.documents[ state.activeIndex ];
const ratio = width / document.width;
document.width = width;
document.height = height;
document.layers?.forEach( layer => {
layer.width = width;
layer.height = height;
});
runSpriteFn( sprite => {
sprite.setBounds( sprite.getX() * ratio, sprite.getY() * ratio, width, height );
});
runSpriteFn( sprite => sprite.resize( width, height ), document );
},
addNewDocument( state, nameOrDocument ) {
const document = typeof nameOrDocument === "object" ? nameOrDocument : DocumentFactory.create({ name: nameOrDocument });

View File

@@ -72,7 +72,8 @@ export const base64ToLayerImage = async( base64, type, width, height ) => {
return null;
};
export const resizeImage = async ( image, srcWidth, srcHeight, targetWidth, targetHeight, mime, encoderOptions ) => {
export const resizeImage = async ( image, srcWidth, srcHeight, targetWidth, targetHeight ) =>
{
if ( srcWidth === targetWidth && srcHeight === targetHeight ) {
return image;
}
@@ -86,5 +87,10 @@ export const resizeImage = async ( image, srcWidth, srcHeight, targetWidth, targ
ctx.drawImage(
image, 0, 0, srcWidth, srcHeight, 0, 0, targetWidth, targetHeight
);
return cvs.toDataURL( mime, encoderOptions );
return cvs;
};
export const resizeToBase64 = async ( image, srcWidth, srcHeight, targetWidth, targetHeight, mime, encoderOptions ) => {
const cvs = await resizeImage( image, srcWidth, srcHeight, targetWidth, targetHeight );
return cvs.toDataURL( mime, encoderOptions );
}

View File

@@ -0,0 +1,85 @@
import { sprite } from "zcanvas";
import {
getCanvasInstance, setCanvasInstance,
createSpriteForLayer, hasSpriteForLayer, getSpriteForLayer, flushLayerSprites,
flushCache
} from "@/factories/sprite-factory";
describe( "Sprite factory", () => {
describe( "when maintaining the active zCanvas instance", () => {
it( "should not have an instance by default", () => {
expect( getCanvasInstance() ).toBeNull();
});
it( "should be able to set and retrieve the active zCanvas instance", () => {
const zCanvas = { name: "zcanvas" };
setCanvasInstance( zCanvas );
expect( getCanvasInstance() ).toEqual( zCanvas );
});
it( "should be able to unset the active zCanvas instance", () => {
expect( getCanvasInstance() ).not.toBeNull(); // set in previous test
setCanvasInstance( null );
expect( getCanvasInstance() ).toBeNull();
});
});
describe( "when lazily creating / caching sprites", () => {
let zCanvas, layer1sprite;
const layer1 = { id: "layer1", x: 0, y: 0, width: 10, height: 10 };
const layer2 = { id: "layer2", x: 0, y: 0, width: 10, height: 10 };
beforeEach(() => {
zCanvas = { name: "zcanvas", addChild: jest.fn() };
});
it( "should create a new Sprite on first request", () => {
layer1sprite = createSpriteForLayer( zCanvas, layer1 );
expect( layer1sprite instanceof sprite );
expect( zCanvas.addChild ).toHaveBeenCalledWith( layer1sprite );
});
it( "should know when there is a Sprite cached for a layer", () => {
expect( hasSpriteForLayer( layer1 )).toBe( true );
expect( hasSpriteForLayer( layer2 )).toBe( false );
});
it( "should retrieve the cached Sprite on repeated invocation for the same layer", () => {
const newSprite = createSpriteForLayer( zCanvas, layer1 );
expect( newSprite ).toEqual( layer1sprite );
expect( zCanvas.addChild ).not.toHaveBeenCalledWith( layer1sprite );
});
it( "should be able to retrieve the cached Sprite for a layer", () => {
expect( getSpriteForLayer( layer1 )).toEqual( layer1sprite );
expect( getSpriteForLayer( layer2 )).toBeNull(); // no Sprite registered
});
it( "should be able to dispose all Sprites for a layer", () => {
jest.spyOn( layer1sprite, "dispose" );
flushLayerSprites( layer1 );
expect( getSpriteForLayer( layer1 )).toBeNull();
expect( layer1sprite.dispose ).toHaveBeenCalled();
});
});
it( "should be able to flush its cache in its entierity", () => {
const zCanvas = { name: "zcanvas", addChild: jest.fn() };
const layer1 = { id: "layer1", x: 0, y: 0, width: 10, height: 10 };
const layer2 = { id: "layer2", x: 0, y: 0, width: 10, height: 10 };
const layer1sprite = createSpriteForLayer( zCanvas, layer1 );
const layer2sprite = createSpriteForLayer( zCanvas, layer2 );
jest.spyOn( layer1sprite, "dispose" );
jest.spyOn( layer2sprite, "dispose" );
flushCache();
expect( hasSpriteForLayer( layer1 )).toBe( false );
expect( hasSpriteForLayer( layer2 )).toBe( false );
expect( layer1sprite.dispose ).toHaveBeenCalled();
expect( layer2sprite.dispose ).toHaveBeenCalled();
});
});

View File

@@ -65,26 +65,57 @@ describe( "Vuex document module", () => {
});
describe( "mutations", () => {
it( "should be able to set the active Document index", () => {
const state = { activeIndex: 0 };
mutations.setActiveDocument( state, 2 );
expect( state.activeIndex ).toEqual( 2 );
describe( "when setting the active Document", () => {
it( "should be able to set the active Document index", () => {
const state = {
documents: [{ name: "foo" }, { name: "bar" }],
activeIndex: 0
};
mutations.setActiveDocument( state, 1 );
expect( state.activeIndex ).toEqual( 1 );
});
it( "should request the invalidate() method on each Sprite for the given Document", () => {
const state = {
documents: [{ name: "foo" }, { name: "bar" }],
activeIndex: 0
};
mockUpdateFn = jest.fn();
mutations.setActiveDocument( state, 1 );
expect( mockUpdateFn ).toHaveBeenCalledWith( "runSpriteFn", expect.any( Function ), state.documents[ 1 ]);
});
});
it( "should be able to update the active Document size", () => {
const state = {
documents: [
describe( "when setting the active Document size", () => {
it( "should be able to update the active Document size", () => {
const state = {
documents: [
{ name: "foo", width: 30, height: 30 },
{ name: "bar", width: 50, height: 50 }
],
activeIndex : 1,
};
const size = { width: 75, height: 40 };
mutations.setActiveDocumentSize( state, size );
expect( state.documents ).toEqual([
{ name: "foo", width: 30, height: 30 },
{ name: "bar", width: 50, height: 50 }
],
activeIndex : 1,
};
const size = { width: 75, height: 40 };
mutations.setActiveDocumentSize( state, size );
expect( state.documents ).toEqual([
{ name: "foo", width: 30, height: 30 },
{ name: "bar", width: size.width, height: size.height },
]);
{ name: "bar", width: size.width, height: size.height },
]);
});
it( "should request the invalidate() method on each Sprite for the given Document", () => {
const state = {
documents: [
{ name: "foo", width: 30, height: 30 },
{ name: "bar", width: 50, height: 50 }
],
activeIndex : 1,
};
const size = { width: 75, height: 40 };
mockUpdateFn = jest.fn();
mutations.setActiveDocumentSize( state, size );
expect( mockUpdateFn ).toHaveBeenCalledWith( "runSpriteFn", expect.any( Function ), state.documents[ state.activeIndex ]);
});
});
it( "should be able to add a new Document to the list", () => {