Restructured sprite factories, fixed bugs with multiple layer addition

This commit is contained in:
Igor Zinken
2020-12-17 19:27:43 +01:00
parent b887d8dc19
commit 12a75de73d
22 changed files with 348 additions and 201 deletions

View File

@@ -37,17 +37,16 @@ npm run lint
# TODO / Roadmap
* Add layer view to options-panel: allow naming, repositioning, set as mask
* Drawable layers must be added to document (and thus be recalled when switching documents)
* Layer view in options-panel: allow naming, repositioning, set as mask
* Drawable layer bitmap content must be added to layer.bitmap (and thus be recalled when switching documents)
* Add brush options > size, transparency
* opening image as new layer doesn't do proper sizing
* scale logic should move from zoomable-canvas into zCanvas (as handleInteraction needs to transform offsets by zoom ratio, see drawable-layer!)
* scale logic should move from zoomable-canvas into zCanvas (as handleInteraction needs to transform offsets by zoom ratio, see DrawableLayer!)
* adjust scaling (on widescreen images scale in the width, rather than go for full height and zoomed out mode)
* Default canvas background should be transparency blocks (requires zCanvas bg pattern update or just a lowest render layer that isn't part of the document)
* Zoom in should be center based
* 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 canvas-util disposeSprite, keep instance count of usages)
* Unload Blobs when images are no longer used in document (see sprite-factory disposeSprite, keep instance count of usages)
* Export output to image file
* Import / export documents from/to disk|Dropbox
* Restore project by selecting folder from file system

View File

@@ -47,12 +47,13 @@ import ZoomableCanvas from "@/components/ui/zcanvas/zoomable-canvas";
import DrawableLayer from "@/components/ui/zcanvas/drawable-layer";
import { scaleToRatio } from "@/utils/image-math";
import {
createSpriteForGraphic, runSpriteFn, flushSpritesInLayer, flushCache,
} from "@/utils/canvas-util";
createSpriteForLayer, runSpriteFn, flushLayerSprites, flushCache,
} from "@/factories/sprite-factory";
/* internal non-reactive properties */
let lastDocument, drawableLayer;
let lastDocument;
const layerPool = new Map();
// scale of the on-screen canvas relative to the document
let xScale = 1, yScale = 1, zoom = 1, containerSize;
@@ -68,6 +69,7 @@ export default {
]),
...mapGetters([
"activeDocument",
"layers",
"activeTool",
"zoomOptions",
]),
@@ -78,7 +80,6 @@ export default {
this.scaleCanvas();
},
activeDocument: {
deep: true,
handler( document, oldValue = null ) {
if ( !document?.layers ) {
if ( this.zCanvas ) {
@@ -95,22 +96,35 @@ export default {
this.scaleCanvas();
});
}
const { name, width, height } = document;
if ( name !== lastDocument ) {
lastDocument = name;
const { id, width, height } = document;
if ( id !== lastDocument ) {
lastDocument = id;
flushCache(); // switching between documents
}
if ( this.zCanvas.width !== width || this.zCanvas.height !== height ) {
this.scaleCanvas();
}
document.layers.forEach( layer => {
},
},
layers: {
deep: true,
handler( layers ) {
const seen = [];
layers?.forEach( layer => {
if ( !layer.visible ) {
flushSpritesInLayer( layer );
flushLayerSprites( layer );
return;
}
layer.graphics.forEach( graphic => {
const sprite = createSpriteForGraphic( this.zCanvas, graphic );
});
if ( !layerPool.has( layer.id )) {
const sprite = createSpriteForLayer( this.zCanvas, layer );
layerPool.set( layer.id, sprite );
}
seen.push( layer.id );
});
[ ...layerPool.keys() ].filter( id => !seen.includes( id )).forEach( id => {
console.warn("remove" + id);
flushLayerSprites( layerPool.get( id ));
layerPool.delete( id );
});
},
},
@@ -123,10 +137,6 @@ export default {
isDraggable = true;
break;
case "brush":
if ( !drawableLayer ) {
drawableLayer = new DrawableLayer( this.activeDocument );
this.zCanvas.addChild( drawableLayer );
}
break;
}
runSpriteFn( sprite => sprite.setDraggable( isDraggable || sprite instanceof DrawableLayer ));

View File

@@ -243,6 +243,6 @@ export default {
width: 64px;
height: 64px;
vertical-align: top;
@include textOverflow();
@include truncate();
}
</style>

View File

@@ -28,7 +28,7 @@
v-t="'loginToDropbox'"
type="button"
class="button dropbox"
@click="login"
@click="login()"
></button>
</template>
<template v-if="authenticated || awaitingConnection">
@@ -37,7 +37,7 @@
type="button"
class="button dropbox"
:disabled="awaitingConnection"
@click="openFileBrowser"
@click="openFileBrowser()"
></button>
</template>
</div>
@@ -62,7 +62,7 @@ export default {
}),
computed: {
awaitingConnection() {
return !this.isauthenticated && !this.authUrl;
return !this.authenticated && !this.authUrl;
},
},
async created() {
@@ -76,6 +76,7 @@ export default {
window.dropboxClientId || localStorage?.getItem( "dropboxClientId" ),
window.dropboxRedirect || `${window.location.href}login.html`
);
this.login();
}
this.loading = false;
},
@@ -101,6 +102,7 @@ export default {
}
},
openFileBrowser() {
console.warn("alstu?");
this.openModal( DROPBOX_FILE_SELECTOR );
},
showConnectionMessage() {

View File

@@ -73,7 +73,11 @@ export default {
"setActiveLayerIndex",
]),
requestLayerAdd() {
this.addLayer( this.$t( "newLayerNum", { num: this.layers.length + 1 }));
this.addLayer({
name: this.$t( "newLayerNum", { num: this.layers.length + 1 }),
width: this.activeDocument.width,
height: this.activeDocument.height
});
},
requestLayerRemove() {
this.removeLayer( this.activeLayer );
@@ -93,6 +97,7 @@ h3 {
.layer-list {
padding: $spacing-small 0;
@include boxSize();
@include truncate();
border-top: 1px solid $color-lines;
border-bottom: 1px solid $color-lines;
}

View File

@@ -34,9 +34,9 @@
<script>
import { mapGetters, mapMutations } from "vuex";
import Slider from "@/components/ui/slider/slider";
import tools from "@/definitions/tools";
import messages from "./messages.json";
import Slider from "@/components/ui/slider/slider";
import ToolTypes from "@/definitions/tool-types";
import messages from "./messages.json";
const MAX_ZOOM = 3;
@@ -59,7 +59,7 @@ export default {
},
set( value ) {
this.setToolOptionValue({
tool: tools.ZOOM,
tool: ToolTypes.ZOOM,
option: "level",
value: ( value / this.max ) * MAX_ZOOM
});

View File

@@ -36,7 +36,10 @@
:key="button.type"
v-t="button.i18n"
class="tool-button"
:class="{ 'active': activeTool === button.type }"
:class="{
'active': activeTool === button.type
}"
:disabled="button.disabled"
@click="setActiveTool( button.type )"
></button>
</div>
@@ -45,6 +48,8 @@
<script>
import { mapState, mapGetters, mapMutations } from "vuex";
import { LAYER_GRAPHIC } from "@/definitions/image-types";
import ToolTypes from "@/definitions/tool-types";
import messages from "./messages.json";
export default {
@@ -55,6 +60,8 @@ export default {
]),
...mapGetters([
"activeTool",
"activeDocument",
"activeLayer",
]),
collapsed: {
get() {
@@ -66,11 +73,34 @@ export default {
},
tools() {
return [
{ type: "move", i18n: "move" }, { type: "zoom", i18n: "zoom" },
{ type: "brush", i18n: "brush" }
{ type: ToolTypes.MOVE, i18n: "move", disabled: !this.activeDocument },
{ type: ToolTypes.ZOOM, i18n: "zoom", disabled: !this.activeDocument },
{ type: ToolTypes.BRUSH, i18n: "brush", disabled: !this.activeLayer || this.activeLayer.type !== LAYER_GRAPHIC }
]
},
},
watch: {
activeDocument( document ) {
if ( !document ) {
this.setActiveTool( null );
}
},
activeLayer( layer ) {
if ( !layer ) {
return;
}
switch ( layer.type ) {
default:
// brushing only allowed on graphic type layers
if ( this.activeTool === ToolTypes.BRUSH ) {
this.setActiveTool( ToolTypes.MOVE );
}
break;
case LAYER_GRAPHIC:
break;
}
},
},
methods: {
...mapMutations([
"setActiveTool",

View File

@@ -24,6 +24,7 @@ import { sprite } from "zcanvas";
import { createCanvas } from "@/utils/canvas-util";
function DrawableLayer({ width, height }) {
console.warn(width,height);
const { cvs, ctx } = createCanvas( width, height );
const opts = {
bitmap: cvs, x: 0, y: 0, width, height

View File

@@ -1,25 +1,25 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2019-2020 - 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.
*/
* The MIT License (MIT)
*
* Igor Zinken 2020 - 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.
*/
const JPEG = "image/jpeg";
const PNG = "image/png";
const GIF = "image/gif";

View File

@@ -20,26 +20,6 @@
* 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.
*/
let UID_COUNTER = 0;
export default {
/**
* Creates a graphic to be displayed within a layer of a Document
*
* @param {HTMLImageElement} bitmap
* @param {Number=} x position of the bitmap within the layer
* @param {Number=} y position of the bitmap within the layer
* @param {Number=} width of the bitmap, defaults to actual bitmap width
* @param {Number=} height of the bitmap, defaults to actual bitmap width
*/
create( bitmap, x = 0, y = 0, width = -1, height = -1 ) {
if ( width === -1 || height === -1 ) {
({ width, height } = bitmap);
}
const transparent = true; // TODO: MIME check
return {
id: `graphic_${( ++UID_COUNTER )}`,
bitmap, x, y, width, height, transparent
};
},
};
export const LAYER_GRAPHIC = "graphic";
export const LAYER_IMAGE = "image";
export const LAYER_MASK = "mask";

View File

@@ -20,6 +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.
*/
let UID_COUNTER = 0;
import LayerFactory from "./layer-factory";
export default {
@@ -27,12 +29,15 @@ export default {
* Creates a new Document (project which contains
* all layers and image content)
*/
create( name = "New document", width = 400, height = 300 ) {
create({
name = "New document", width = 400, height = 300
} = {}) {
return {
id: `doc_${( ++UID_COUNTER )}`,
name,
width,
height,
layers: [ LayerFactory.create() ],
layers: [ LayerFactory.create({ width, height }) ],
};
},
};

View File

@@ -20,16 +20,64 @@
* 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 default {
import {
LAYER_GRAPHIC, LAYER_IMAGE, LAYER_MASK
} from "@/definitions/layer-types";
let UID_COUNTER = 0;
const LayerFactory = {
/**
* Creates a new layer for use within a Document
*/
create( name = "New Layer", graphics = [] ) {
create({
name = "New Layer",
type = LAYER_GRAPHIC, bitmap = null,
x = 0, y = 0, width = 1, height = 1, visible = true
} = {}) {
return {
id: `layer_${( ++UID_COUNTER )}`,
name,
graphics,
masks: [],
visible: true
type,
bitmap,
x,
y,
width,
height,
visible
}
},
/**
* Saving layer properties into a simplified JSON structure
* for project storage
*/
save( layer ) {
return {
n: layer.name,
t: layer.type,
b: null, // TODO how to serialize layer Bitmap data?
x: layer.x,
y: layer.y,
w: layer.width,
h: layer.height,
v: layer.visible,
};
},
/**
* Creating a new layer instance from a stored layer structure
* inside a stored project
*/
load( layer ) {
// TODO how to deserialize layer Bitmap data?
return LayerFactory.create({
name: layer.n, type: layer.t,
bitmap: layer.b,
x: layer.x, y: layer.y,
width: layer.w, height: layer.h,
visible: layer.v
});
}
};
export default LayerFactory;

View File

@@ -0,0 +1,100 @@
/**
* The MIT License (MIT)
*
* Igor Zinken 2020 - 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.
*/
import { sprite } from "zcanvas";
import DrawableLayer from "@/components/ui/zcanvas/drawable-layer";
import { LAYER_IMAGE, LAYER_GRAPHIC, LAYER_MASK } from "@/definitions/layer-types";
/**
* Sprites are used to represent layer content. These are mapped
* to the layer ids (see layer-factory.js)
*/
const spriteCache = new Map();
/**
* Runs given fn on each Sprite in the cache
*/
export const runSpriteFn = fn => {
spriteCache.forEach( fn );
};
/**
* If a layer were to be removed / set to invisible, we
* flush all its cached Sprites.
*/
export const flushLayerSprites = ({ id }) => {
console.warn( `flushing sprite for "${id}"` );
if ( hasSpriteForLayer({ id })) {
disposeSprite( spriteCache.get( id ));
spriteCache.delete( id );
}
};
export const hasSpriteForLayer = ({ id }) => {
return spriteCache.has( id );
};
/**
* Clears the entire cache and disposes all Sprites.
*/
export const flushCache = () => {
console.warn( "flushing sprite cache" );
spriteCache.forEach( disposeSprite );
spriteCache.clear();
};
/**
* Lazily retrieve / create a cached sprite to represent given
* layer content on given zCanvas instance
*/
export const createSpriteForLayer = ( zCanvasInstance, { id, type, bitmap, x, y, width, height }) => {
let output;
if ( hasSpriteForLayer({ id })) {
output = spriteCache.get( id );
}
// lazily create sprite
if ( !output ) {
switch ( type ) {
default:
case LAYER_GRAPHIC:
output = new DrawableLayer({ width, height });
break;
case LAYER_IMAGE:
output = new sprite({
bitmap, x, y, width, height
});
break;
}
output.setDraggable( true );
zCanvasInstance.addChild( output );
spriteCache.set( id, output );
}
return output;
};
/* internal methods */
function disposeSprite( sprite ) {
console.warn( "disposing sprite" );
sprite?.dispose();
// TODO: also free associated bitmap ?
}

View File

@@ -20,7 +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{ mapGetters, mapMutations, mapActions } from "vuex";
import { mapGetters, mapMutations, mapActions } from "vuex";
import { LAYER_IMAGE } from "@/definitions/layer-types";
export default {
computed: {
@@ -35,7 +36,7 @@ export default {
"setActiveDocumentSize",
"addNewDocument",
"addLayer",
"addGraphicToLayer",
"updateLayer",
]),
...mapActions([
"addImage",
@@ -45,27 +46,36 @@ export default {
image.src = source;
const currentDocumentIsEmpty = this.layers?.length === 1 && !this.layers[ 0 ].graphics.length;
const currentDocumentIsEmpty = this.layers?.length <= 1 && this.layers?.[0]?.bitmap === null;
const layerOpts = {
bitmap: image,
type: LAYER_IMAGE,
name: file.name,
...size,
};
switch ( this.fileTarget) {
default:
case "layer":
// if this is the first content of an existing document, scale document to image size
if ( currentDocumentIsEmpty ) {
this.setActiveDocumentSize( size );
} else if ( !this.activeDocument ) {
if ( !this.activeDocument ) {
this.addNewDocument( file.name );
return this.updateSizeAndLayer( size, layerOpts );
} else if ( currentDocumentIsEmpty ) {
this.setActiveDocumentSize( size );
}
this.addLayer( file.name );
this.addLayer( layerOpts );
break;
case "document":
if ( !currentDocumentIsEmpty ) {
this.addNewDocument( file.name );
}
this.setActiveDocumentSize( size );
this.updateSizeAndLayer( size, layerOpts );
break;
}
this.addGraphicToLayer({ index: this.layers.length - 1, bitmap: image, size });
},
}
updateSizeAndLayer( size, opts ) {
this.setActiveDocumentSize( size );
this.updateLayer({ index: this.layers.length - 1, opts });
},
}
};

View File

@@ -20,7 +20,7 @@
* 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 { createCanvas } from './canvas-util';
import { createCanvas } from "@/utils/canvas-util";
const { URL } = window;

View File

@@ -24,8 +24,7 @@ import Vue from "vue";
import DocumentFactory from "@/factories/document-factory";
import LayerFactory from "@/factories/layer-factory";
import GraphicFactory from "@/factories/graphic-factory";
import { flushSpritesInLayer } from "@/utils/canvas-util";
import { flushLayerSprites } from "@/factories/sprite-factory";
export default {
state: {
@@ -50,7 +49,7 @@ export default {
document.height = height;
},
addNewDocument( state, name ) {
state.documents.push( DocumentFactory.create( name ));
state.documents.push( DocumentFactory.create({ name }));
state.activeIndex = state.documents.length - 1;
},
closeActiveDocument( state ) {
@@ -59,13 +58,13 @@ export default {
return;
}
// free allocated resources
document.layers.forEach( layer => flushSpritesInLayer( layer ));
document.layers.forEach( layer => flushLayerSprites( layer ));
Vue.delete( state.documents, state.activeIndex );
state.activeIndex = Math.min( state.documents.length - 1, state.activeIndex );
},
addLayer( state, optName ) {
addLayer( state, opts ) {
const layers = state.documents[ state.activeIndex ].layers;
layers.unshift( LayerFactory.create( optName ) );
layers.unshift( LayerFactory.create( opts ) );
state.activeLayerIndex = 0;
},
removeLayer( state, layer ) {
@@ -73,16 +72,18 @@ export default {
if ( index < 0 ) {
return;
}
flushSpritesInLayer( layer );
flushLayerSprites( layer );
Vue.delete( state.documents[ state.activeIndex ].layers, index );
},
setActiveLayerIndex( state, index ) {
state.activeLayerIndex = index;
},
addGraphicToLayer( state, { index, bitmap, size = {} }) {
state.documents[ state.activeIndex ].layers[ index ]?.graphics.push(
GraphicFactory.create( bitmap, 0, 0, size.width, size.height )
);
updateLayer( state, { index, opts = {} }) {
const layer = state.documents[ state.activeIndex ].layers[ index ];
Vue.set( state.documents[ state.activeIndex ].layers, index, {
...layer,
...opts
});
},
},
actions: {

View File

@@ -20,20 +20,20 @@
* 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 Vue from "vue";
import tools from "@/definitions/tools";
import Vue from "vue";
import ToolTypes from "@/definitions/tool-types";
export default {
state: {
activeTool: tools.MOVE,
activeTool: null,
options: {
[ tools.ZOOM ] : { level: 1 },
[ tools.BRUSH ]: { },
[ ToolTypes.ZOOM ] : { level: 1 },
[ ToolTypes.BRUSH ]: { },
}
},
getters: {
activeTool: state => state.activeTool,
zoomOptions: state => state.options[ tools.ZOOM ],
zoomOptions: state => state.options[ ToolTypes.ZOOM ],
},
mutations: {
setActiveTool( state, tool ) {

View File

@@ -75,7 +75,7 @@
box-sizing: border-box;
}
@mixin textOverflow {
@mixin truncate {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;

View File

@@ -20,13 +20,6 @@
* 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";
/**
* Sprites are used to represend graphics. These are mapped
* to the graphic ids (see graphic-factory.js)
*/
const spriteCache = new Map();
/**
* Creates a new HTMLCanvasElement, returning both
@@ -42,61 +35,3 @@ export const createCanvas = ( optWidth = 0, optHeight = 0 ) => {
}
return { cvs, ctx };
};
/**
* Runs given fn on each Sprite in the cache
*/
export const runSpriteFn = fn => {
spriteCache.forEach( fn );
};
/**
* If a layer were to be removed / set to invisible, we
* flush all its cached Sprites.
*/
export const flushSpritesInLayer = layer => {
console.warn("flushing sprites in layer");
layer.graphics.forEach(({ id }) => {
if ( spriteCache.has( id )) {
disposeSprite( spriteCache.get( id ));
spriteCache.delete( id );
}
});
};
/**
* Clears the entire cache and disposes all Sprites.
*/
export const flushCache = () => {
console.warn("flushing cache");
spriteCache.forEach( disposeSprite );
spriteCache.clear();
};
/**
* Lazily retrieve / create a cached sprite to represent given
* graphic on given zCanvas instance
*/
export const createSpriteForGraphic = ( zCanvasInstance, { id, bitmap, x, y, width, height }) => {
let output;
if ( spriteCache.has( id )) {
output = spriteCache.get( id );
}
// lazily create sprite
if ( !output ) {
output = new sprite({
bitmap, x, y, width, height
});
output.setDraggable( true );
zCanvasInstance.addChild( output );
spriteCache.set( id, output );
}
return output;
};
/* internal methods */
function disposeSprite( sprite ) {
sprite?.dispose();
// TODO: also free associated bitmap ?
}

View File

@@ -20,7 +20,7 @@
* 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 { createCanvas } from './canvas-util';
import { createCanvas } from "@/utils/canvas-util";
const { URL } = window;

View File

@@ -1,12 +1,14 @@
import DocumentModule from "@/store/modules/document-module";
import GraphicFactory from "@/factories/graphic-factory";
import LayerFactory from "@/factories/layer-factory";
import DocumentModule from "@/store/modules/document-module";
import { LAYER_IMAGE } from "@/definitions/layer-types";
const { getters, mutations } = DocumentModule;
let mockUpdateFn;
jest.mock("@/utils/canvas-util", () => ({
flushSpritesInLayer: (...args) => mockUpdateFn?.( "flushSpritesInLayer", ...args ),
jest.mock( "@/factories/sprite-factory", () => ({
flushLayerSprites: (...args) => mockUpdateFn?.( "flushLayerSprites", ...args ),
}));
jest.mock( "@/factories/layer-factory", () => ({
create: (...args) => mockUpdateFn?.( "create", ...args ),
}));
describe( "Vuex document module", () => {
@@ -81,9 +83,9 @@ describe( "Vuex document module", () => {
});
it( "should be able to close the active Document", () => {
const layer1 = LayerFactory.create();
const layer2 = LayerFactory.create();
const layer3 = LayerFactory.create();
const layer1 = { name: "layer1" };
const layer2 = { name: "layer2" };
const layer3 = { name: "layer3" };
const state = {
documents: [
{ name: "foo", layers: [ layer1 ] },
@@ -95,8 +97,8 @@ describe( "Vuex document module", () => {
mutations.closeActiveDocument( state );
expect( state.documents ).toEqual([ { name: "foo", layers: [ layer1 ] }]);
expect( state.activeIndex ).toEqual( 0 );
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 1, "flushSpritesInLayer", layer2 );
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 2, "flushSpritesInLayer", layer3 );
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 1, "flushLayerSprites", layer2 );
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 2, "flushLayerSprites", layer3 );
});
describe( "when adding layers", () => {
@@ -105,8 +107,13 @@ describe( "Vuex document module", () => {
documents: [ { name: "foo", layers: [] } ],
activeIndex: 0
};
mutations.addLayer( state );
expect( state.documents[ 0 ].layers ).toEqual([ LayerFactory.create() ]);
const mockLayer = { name: "bar" };
mockUpdateFn = jest.fn(() => mockLayer );
const opts = { name: "baz", width: 50, height: 100 };
mutations.addLayer( state, opts );
// assert LayerFactory is invoked with provided opts when calling addLayer()
expect( mockUpdateFn ).toHaveBeenCalledWith( "create", opts );
expect( state.documents[ 0 ].layers ).toEqual([ mockLayer ]);
});
it( "should update the active layer index to the last added layers index", () => {
@@ -124,9 +131,9 @@ describe( "Vuex document module", () => {
documents: [ { name: "foo", layers: [{ name: "layer1" }] }],
activeIndex: 0
};
mutations.addLayer( state, "layer2" );
mutations.addLayer( state, { name: "layer2" });
expect( state.documents[ 0 ].layers ).toEqual([
LayerFactory.create( "layer2" ), { name: "layer1" }
expect.any( Object ), { name: "layer1" }
]);
});
});
@@ -145,7 +152,7 @@ describe( "Vuex document module", () => {
expect( state.documents[ 0 ].layers ).toEqual([
{ name: "layer1" }, { name: "layer3" }
]);
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 1, "flushSpritesInLayer", layer );
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 1, "flushLayerSprites", layer );
});
it( "should be able to set the active layer index", () => {
@@ -157,18 +164,32 @@ describe( "Vuex document module", () => {
expect( state.activeLayerIndex ).toEqual( 1 );
});
it( "should be able to add a Graphic to a specific layer within the active Document", () => {
it( "should be able to update the options of a specific layer within the active Document", () => {
const layer1 = { name: "layer1" };
const layer2 = { name: "layer2" };
const state = {
documents: [{
name: "foo",
layers: [ LayerFactory.create( "layer1" ), LayerFactory.create( "layer2" ) ]
layers: [ layer1, layer2 ]
}],
activeIndex: 0
};
const index = 1;
const bitmap = { name: "bar" };
mutations.addGraphicToLayer( state, { index, bitmap });
expect( state.documents[ 0 ].layers[ index ].graphics ).toEqual( expect.any( Object ));
const index = 1;
const opts = {
name: "layer2 updated",
x: 100,
y: 200,
bitmap: new Image(),
width: 100,
height: 150,
type: LAYER_IMAGE
};
mutations.updateLayer( state, { index, opts });
expect( state.documents[ 0 ].layers[ index ] ).toEqual({
id: layer2.id,
visible: layer2.visible,
...opts
});
});
});
});