Implemented layer mirroring effect

This commit is contained in:
Igor Zinken
2020-12-29 12:46:50 +01:00
parent 1bc68cb8e5
commit ba5cf0e3b7
19 changed files with 298 additions and 66 deletions

View File

@@ -56,6 +56,7 @@ npm run lint
# TODO / Roadmap
* Moving of masks broken ?
* Issue with drawing mask on mirrored content
* Verify saving and restoring of masked and rotated and positioned content
* Transparency blocks should be rendered by zCanvas, not parent div
* Copying selections on rotated content doesn't work

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Icons" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 32 32" style="enable-background:new 0 0 32 32;" xml:space="preserve">
<style type="text/css">
.st0{fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
.st1{fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;}
.st2{fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:6,6;}
.st3{fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:4,4;}
.st4{fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;}
.st5{fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-dasharray:3.1081,3.1081;}
.st6{fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;stroke-dasharray:4,3;}
</style>
<polygon class="st0" points="12,22 3,27 3,5 12,10 "/>
<polygon class="st0" points="20,10 29,5 29,27 20,22 "/>
<line class="st0" x1="16" y1="3" x2="16" y2="6"/>
<line class="st0" x1="16" y1="27" x2="16" y2="30"/>
<line class="st0" x1="16" y1="15" x2="16" y2="18"/>
<line class="st0" x1="16" y1="9" x2="16" y2="12"/>
<line class="st0" x1="16" y1="21" x2="16" y2="24"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -160,6 +160,7 @@ export default {
<style lang="scss" scoped>
@import "@/styles/_mixins";
@import "@/styles/typography";
@import "@/styles/options-panel";
h3 {
color: #FFF;
@@ -203,14 +204,4 @@ h3 {
color: #FFF;
}
}
.actions {
margin-top: $spacing-small;
display: flex;
button {
flex: 1;
margin: 0 $spacing-small;
}
}
</style>

View File

@@ -74,5 +74,5 @@ export default {
</script>
<style lang="scss" scoped>
@import "@/styles/tool-option";
@import "@/styles/options-panel";
</style>

View File

@@ -94,5 +94,5 @@ export default {
</script>
<style lang="scss" scoped>
@import "@/styles/tool-option";
@import "@/styles/options-panel";
</style>

View File

@@ -0,0 +1,7 @@
{
"en-US": {
"mirror": "Mirror",
"flipHorizontal": "Flip horizontal",
"flipVertical": "Flip vertical"
}
}

View File

@@ -0,0 +1,77 @@
/**
* 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.
*/
<template>
<div class="tool-option">
<h3 v-t="'mirror'"></h3>
<div class="actions">
<button
v-t="'flipHorizontal'"
type="button"
class="button button--small"
@click="flipHorizontal"
></button>
<button
v-t="'flipVertical'"
type="button"
class="button button--small"
@click="flipVertical"
></button>
</div>
</div>
</template>
<script>
import { mapGetters, mapMutations } from "vuex";
import messages from "./messages.json";
export default {
i18n: { messages },
computed: {
...mapGetters([
"activeLayerIndex",
"activeLayerEffects",
]),
},
methods: {
...mapMutations([
"updateLayerEffects",
]),
flipHorizontal() {
this.updateLayerEffects({
index: this.activeLayerIndex,
effects: { mirrorX: !this.activeLayerEffects.mirrorX }
});
},
flipVertical() {
this.updateLayerEffects({
index: this.activeLayerIndex,
effects: { mirrorY: !this.activeLayerEffects.mirrorY }
});
},
},
};
</script>
<style lang="scss" scoped>
@import "@/styles/options-panel";
</style>

View File

@@ -75,5 +75,5 @@ export default {
</script>
<style lang="scss" scoped>
@import "@/styles/tool-option";
@import "@/styles/options-panel";
</style>

View File

@@ -95,14 +95,5 @@ export default {
</script>
<style lang="scss" scoped>
@import "@/styles/tool-option";
.actions {
display: flex;
margin-top: $spacing-medium;
button {
flex: 1;
margin: 0 $spacing-small;
}
}
@import "@/styles/options-panel";
</style>

View File

@@ -126,6 +126,8 @@ export default {
return () => import( "./components/tool-options-brush/tool-options-brush" );
case ToolTypes.ROTATE:
return () => import( "./components/tool-options-rotate/tool-options-rotate" );
case ToolTypes.MIRROR:
return () => import( "./components/tool-options-mirror/tool-options-mirror" );
}
},
},

View File

@@ -5,6 +5,7 @@
"polygonalLasso": "Polygonal lasso",
"eyedropper": "Eyedropper",
"rotateLayer": "Rotate layer",
"mirrorLayer": "Mirror layer",
"zoom": "Zoom",
"eraser": "Eraser",
"brush": "Brush",

View File

@@ -35,7 +35,7 @@
<button v-for="(tool, index) in tools"
:key="tool.type"
type="button"
v-tooltip="$t( tool.i18n )"
v-tooltip="`${$t( tool.i18n )} (${tool.key})`"
:title="$t( tool.i18n )"
class="tool-button"
:class="{
@@ -92,37 +92,42 @@ export default {
return [
{
type: ToolTypes.MOVE,
i18n: "dragLayer", icon: "drag",
i18n: "dragLayer", icon: "drag", key: "M",
disabled: !this.activeDocument
},
{
type: ToolTypes.LASSO,
i18n: "polygonalLasso", icon: "selection",
i18n: "polygonalLasso", icon: "selection", key: "L",
disabled: !this.activeDocument
},
{
type: ToolTypes.EYEDROPPER,
i18n: "eyedropper", icon: "eyedropper",
i18n: "eyedropper", icon: "eyedropper", key: "I",
disabled: !this.activeLayer
},
{
type: ToolTypes.ROTATE,
i18n: "rotateLayer", icon: "rotate",
i18n: "rotateLayer", icon: "rotate", key: "R",
disabled: !this.activeLayer
},
{
type: ToolTypes.MIRROR,
i18n: "mirrorLayer", icon: "mirror", key: "F",
disabled: !this.activeLayer
},
{
type: ToolTypes.ERASER,
i18n: "eraser", icon: "eraser",
i18n: "eraser", icon: "eraser", key: "E",
disabled: !this.activeDocument || !( this.activeLayer?.mask || this.activeLayer?.type === LAYER_GRAPHIC )
},
{
type: ToolTypes.BRUSH,
i18n: "brush", icon: "paintbrush",
i18n: "brush", icon: "paintbrush", key: "B",
disabled: !this.activeDocument || !( this.activeLayer?.mask || this.activeLayer?.type === LAYER_GRAPHIC )
},
{
type: ToolTypes.ZOOM,
i18n: "zoom", icon: "zoom",
i18n: "zoom", icon: "zoom", key: "Z",
disabled: !this.activeDocument
},
]

View File

@@ -0,0 +1,56 @@
/**
* 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 EffectFactory = {
create({ rotation = 0, mirrorX = false, mirrorY = false } = {}) {
return {
rotation,
mirrorX,
mirrorY,
};
},
/**
* Saving effects properties into a simplified JSON structure
* for project storage
*/
serialize( effects ) {
return {
r: effects.rotation,
x: effects.mirrorX,
y: effects.mirrorY,
};
},
/**
* Creating a new effects lists from a stored effects structure
* inside a stored projects layer
*/
deserialize( effects ) {
return EffectFactory.create({
rotation: effects.r,
mirrorX: effects.x,
mirrorY: effects.y,
});
}
};
export default EffectFactory;

View File

@@ -22,6 +22,7 @@
*/
import { LAYER_GRAPHIC, LAYER_MASK } from "@/definitions/layer-types";
import { imageToBase64, base64ToLayerImage } from "@/utils/canvas-util";
import EffectsFactory from "@/factories/effects-factory";
let UID_COUNTER = 0;
const LayerFactory = {
@@ -32,7 +33,7 @@ const LayerFactory = {
name = "New Layer",
type = LAYER_GRAPHIC, source = null, mask = null,
x = 0, y = 0, maskX = 0, maskY = 0, width = 1, height = 1, visible = true,
effects = { rotation: 0 }
effects = {}
} = {}) {
return {
id: `layer_${( ++UID_COUNTER )}`,
@@ -47,7 +48,7 @@ const LayerFactory = {
width,
height,
visible,
effects,
effects: EffectsFactory.create( effects ),
// only used at runtime, will not be serialized
selection: null,
}
@@ -69,7 +70,7 @@ const LayerFactory = {
y2: layer.maskY,
w: layer.width,
h: layer.height,
fx: layer.effects,
f: EffectsFactory.serialize( layer.effects ),
v: layer.visible,
};
},
@@ -93,7 +94,7 @@ const LayerFactory = {
width: layer.w,
height: layer.h,
visible: layer.v,
effects: layer.fx,
effects: EffectsFactory.deserialize( layer.f ),
});
}
};

View File

@@ -204,6 +204,12 @@ function handleKeyDown( event ) {
}
break;
case 70: // F
if ( getters.activeLayer ) {
setActiveTool( ToolTypes.MIRROR );
}
break;
case 73: // I
if ( getters.activeLayer ) {
setActiveTool( ToolTypes.EYEDROPPER );

View File

@@ -47,7 +47,7 @@ export const renderEffectsForLayer = async layer => {
const ctx = cvs.getContext( "2d" );
if ( hasEffects( layer )) {
await renderTransformedSource( layer, ctx, layer.source, width, height, effects.rotation );
await renderTransformedSource( layer, ctx, layer.source, width, height, effects );
} else {
ctx.drawImage( layer.source, 0, 0 );
}
@@ -60,16 +60,31 @@ export const renderEffectsForLayer = async layer => {
/* internal methods */
const hasEffects = ( layer ) => {
if ( !!layer.mask ) {
return true;
}
const { effects } = layer;
return !!layer.mask || effects.rotation !== 0;
return effects.rotation !== 0 || effects.mirrorX || effects.mirrorY;
};
const renderTransformedSource = async ( layer, ctx, sourceBitmap, width, height, rotation ) => {
const renderTransformedSource = async ( layer, ctx, sourceBitmap, width, height, { mirrorX, mirrorY, rotation }) => {
const rotate = ( rotation % 360 ) !== 0;
let targetX = 0, targetY = 0;
let targetX = mirrorX ? -width : 0;
let targetY = mirrorY ? -height : 0;
const xScale = mirrorX ? -1 : 1;
const yScale = mirrorY ? -1 : 1;
ctx.save();
ctx.scale( xScale, yScale );
if ( rotate ) {
const { x, y } = getRotationCenter({ left: 0, top: 0, width, height });
ctx.save();
const { x, y } = getRotationCenter({
left : 0,
top : 0,
width : mirrorX ? -width : width,
height : mirrorY ? -height : height
});
ctx.translate( x, y );
ctx.rotate( rotation );
ctx.translate( -x, -y );
@@ -78,9 +93,8 @@ const renderTransformedSource = async ( layer, ctx, sourceBitmap, width, height,
}
ctx.drawImage( sourceBitmap, targetX, targetY );
await renderMask( layer, ctx, targetX, targetY );
if ( rotate ) {
ctx.restore();
}
ctx.restore();
}
const renderMask = async( layer, ctx, tX = 0, tY = 0 ) => {

View File

@@ -10,3 +10,13 @@
color: #FFF;
}
}
.actions {
margin: $spacing-small 0;
display: flex;
button {
flex: 1;
margin: 0 $spacing-small;
}
}

View File

@@ -0,0 +1,41 @@
import EffectsFactory from "@/factories/effects-factory";
describe( "Effects factory", () => {
describe( "when creating a new Effects list", () => {
it( "should create a default Effects structure when no arguments are passed", () => {
const effects = EffectsFactory.create();
expect( effects ).toEqual({
mirrorX: false,
mirrorY: false,
rotation: expect.any( Number )
});
});
it( "should be able to create a Effects list from given arguments", () => {
const effects = EffectsFactory.create({
mirrorX: true,
mirrorY: true,
rotation: -90
});
expect( effects ).toEqual({
mirrorX: true,
mirrorY: true,
rotation: -90
});
});
});
describe( "when serializing and deserializing a Effects list", () => {
it( "should do so without data loss", async () => {
const effects = EffectsFactory.create({
mirrorX: true,
mirrorY: true,
rotation: 270
});
const serialized = EffectsFactory.serialize( effects );
const deserialized = EffectsFactory.deserialize( serialized );
expect( deserialized ).toEqual( effects );
});
});
});

View File

@@ -6,10 +6,17 @@ jest.mock( "@/utils/canvas-util", () => ({
imageToBase64: (...args) => mockUpdateFn?.( "imageToBase64", ...args ),
base64ToLayerImage: (...args) => mockUpdateFn?.( "base64ToLayerImage", ...args ),
}));
jest.mock( "@/factories/effects-factory", () => ({
create: (...args) => mockUpdateFn?.( "create", ...args ),
serialize: (...args) => mockUpdateFn?.( "serialize", ...args ),
deserialize: (...args) => mockUpdateFn?.( "deserialize", ...args ),
}));
describe( "Layer factory", () => {
describe( "when creating a new layer", () => {
it( "should create a default Layer structure when no arguments are passed", () => {
const mockEffects = { foo: "bar" };
mockUpdateFn = fn => fn === "create" ? mockEffects : {};
const layer = LayerFactory.create();
expect( layer ).toEqual({
id: expect.any( String ),
@@ -24,14 +31,13 @@ describe( "Layer factory", () => {
width: 1,
height: 1,
visible: true,
effects: {
rotation: 0,
},
effects: mockEffects,
selection: null,
});
});
it( "should be able to create a layer from given arguments", () => {
mockUpdateFn = ( fn, data ) => data;
const layer = LayerFactory.create({
name: "foo",
type: LAYER_IMAGE,
@@ -70,32 +76,33 @@ describe( "Layer factory", () => {
});
describe( "when serializing and deserializing a Layer", () => {
const layer = LayerFactory.create({
name: "foo",
type: LAYER_IMAGE,
source: { src: "bitmap" },
mask: { src: "mask" },
x: 100,
y: 50,
width: 16,
height: 9,
visible: false,
effects: {
rotation: -90,
},
});
it( "should do so without data loss", async () => {
mockUpdateFn = jest.fn(( fn, data ) => JSON.stringify( data ));
const layer = LayerFactory.create({
name: "foo",
type: LAYER_IMAGE,
source: { src: "bitmap" },
mask: { src: "mask" },
x: 100,
y: 50,
width: 16,
height: 9,
visible: false,
effects: {
rotation: -90,
},
});
mockUpdateFn = jest.fn(( fn, data ) => data );
const serialized = LayerFactory.serialize( layer );
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 1, "imageToBase64", layer.source, layer.width, layer.height );
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 2, "imageToBase64", layer.mask, layer.width, layer.height );
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 3, "serialize", layer.effects );
mockUpdateFn = jest.fn(( fn, data ) => JSON.parse( data ));
mockUpdateFn = jest.fn(( fn, data ) => data );
const deserialized = await LayerFactory.deserialize( serialized );
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 1, "base64ToLayerImage", expect.any( String ), LAYER_IMAGE, layer.width, layer.height );
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 2, "base64ToLayerImage", expect.any( String ), LAYER_MASK, layer.width, layer.height );
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 1, "base64ToLayerImage", expect.any( Object ), LAYER_IMAGE, layer.width, layer.height );
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 2, "base64ToLayerImage", expect.any( Object ), LAYER_MASK, layer.width, layer.height );
expect( mockUpdateFn ).toHaveBeenNthCalledWith( 3, "deserialize", layer.effects );
// note id's are unique per created session instance and therefor will differ
expect({