diff --git a/README.md b/README.md
index 3df0c53..e8b713e 100644
--- a/README.md
+++ b/README.md
@@ -55,8 +55,10 @@ npm run lint
# TODO / Roadmap
+* Fix bug that occurs when dragging a rotated layer
* Eraser tool
* Fill selection with color (make color not unique to brush, but generic tool prop)
+* Animate selection lines between white and black colors, scale lines to always appear at same size, regardless of zoom level
* When resizing document, positioned layers do not scale their position correctly
* Layer bitmaps and masks must not be stored as Vue observables
* Make canvas only as large as its wrapper. Use viewport in zCanvas to render visible content (requires custom scroll handlers for panning)
@@ -66,7 +68,7 @@ npm run lint
* Zoom set original size isn't that accurate (check also on mobile views), needs calculateMaxScaling ?
* Unload Blobs when images are no longer used in document (see sprite-factory disposeSprite, keep instance count of usages)
* Load/save documents directly from/to Dropbox
-* Implement layer rotation
+* Load/save selections (into document)
* Implement layer scaling
* Implement rectangular selection
* Implement merged layer selection
diff --git a/src/components/options-panel/components/tool-options-rotate/messages.json b/src/components/options-panel/components/tool-options-rotate/messages.json
new file mode 100644
index 0000000..14f0479
--- /dev/null
+++ b/src/components/options-panel/components/tool-options-rotate/messages.json
@@ -0,0 +1,5 @@
+{
+ "en-US": {
+ "rotation": "Rotation"
+ }
+}
diff --git a/src/components/options-panel/components/tool-options-rotate/tool-options-rotate.vue b/src/components/options-panel/components/tool-options-rotate/tool-options-rotate.vue
new file mode 100644
index 0000000..3947794
--- /dev/null
+++ b/src/components/options-panel/components/tool-options-rotate/tool-options-rotate.vue
@@ -0,0 +1,80 @@
+/**
+ * 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.
+ */
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/options-panel/options-panel.vue b/src/components/options-panel/options-panel.vue
index 03069be..48ef722 100644
--- a/src/components/options-panel/options-panel.vue
+++ b/src/components/options-panel/options-panel.vue
@@ -60,7 +60,8 @@
import { mapState, mapGetters, mapMutations } from "vuex";
import FileSelector from "./components/file-selector/file-selector";
import Layers from "./components/layers/layers";
-import SelectBox from '@/components/ui/select-box/select-box';
+import SelectBox from "@/components/ui/select-box/select-box";
+import ToolTypes from "@/definitions/tool-types";
import { mapSelectOptions } from "@/utils/search-select-util"
import messages from "./messages.json";
@@ -117,10 +118,12 @@ export default {
switch ( this.activeTool ) {
default:
return null;
- case "zoom":
+ case ToolTypes.ZOOM:
return () => import( "./components/tool-options-zoom/tool-options-zoom" );
- case "brush":
+ case ToolTypes.BRUSH:
return () => import( "./components/tool-options-brush/tool-options-brush" );
+ case ToolTypes.ROTATE:
+ return () => import( "./components/tool-options-rotate/tool-options-rotate" );
}
},
},
diff --git a/src/components/toolbox/messages.json b/src/components/toolbox/messages.json
index 4077066..d2be906 100644
--- a/src/components/toolbox/messages.json
+++ b/src/components/toolbox/messages.json
@@ -4,6 +4,7 @@
"move": "Move",
"polygonalLasso": "Polygonal lasso",
"eyedropper": "Eyedropper",
+ "rotateLayer": "Rotate layer",
"zoom": "Zoom",
"brush": "Brush",
"color": "Color"
diff --git a/src/components/toolbox/toolbox.vue b/src/components/toolbox/toolbox.vue
index b52267c..6501daa 100644
--- a/src/components/toolbox/toolbox.vue
+++ b/src/components/toolbox/toolbox.vue
@@ -105,6 +105,11 @@ export default {
i18n: "eyedropper", icon: "eyedropper",
disabled: !this.activeLayer
},
+ {
+ type: ToolTypes.ROTATE,
+ i18n: "rotateLayer", icon: "rotate",
+ disabled: !this.activeLayer
+ },
{
type: ToolTypes.BRUSH,
i18n: "brush", icon: "paintbrush",
diff --git a/src/components/ui/zcanvas/layer-sprite.js b/src/components/ui/zcanvas/layer-sprite.js
index aba442c..97b3dc4 100644
--- a/src/components/ui/zcanvas/layer-sprite.js
+++ b/src/components/ui/zcanvas/layer-sprite.js
@@ -24,7 +24,7 @@ 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, getRotationCenter } from "@/utils/image-math";
import ToolTypes from "@/definitions/tool-types";
/**
@@ -197,6 +197,13 @@ class LayerSprite extends sprite {
this._pointerX = x;
this._pointerY = y;
+ // for rotated content we must translate the pointer coordinates to the unrotated position
+ if ( this.layer.rotation ) {
+ // TODO: this is going all sorts of wrong.
+ const { tX, tY } = getRotationCenter( this._bounds );
+ ({ x, y } = translatePointerRotation( x, y, tX, tY, this.layer.rotation ));
+ }
+
if ( !this._isBrushMode ) {
// not drawable, perform default behaviour (drag)
if ( this.actionTarget === "mask" ) {
@@ -253,6 +260,13 @@ class LayerSprite extends sprite {
}
draw( documentContext ) {
+ if ( this.layer.rotation ) {
+ documentContext.save();
+ const { tX, tY } = getRotationCenter( this._bounds );
+ documentContext.translate( tX, tY );
+ documentContext.rotate( this.layer.rotation );
+ documentContext.translate( -tX, -tY );
+ }
if ( !this.isMaskable() ) {
// use base draw() logic when no mask is set
super.draw( documentContext );
@@ -277,6 +291,9 @@ class LayerSprite extends sprite {
( .5 + height ) << 0
);
}
+ if ( this.layer.rotation ) {
+ documentContext.restore();
+ }
// render brush outline at pointer position
if ( this._isBrushMode ) {
documentContext.save();
diff --git a/src/definitions/tool-types.js b/src/definitions/tool-types.js
index cf43668..07bfa88 100644
--- a/src/definitions/tool-types.js
+++ b/src/definitions/tool-types.js
@@ -25,10 +25,12 @@ import { MAX_IMAGE_SIZE, MAX_MEGAPIXEL } from "@/definitions/image-types";
import { LAYER_GRAPHIC } from "@/definitions/layer-types";
export default {
- MOVE : "move",
- ZOOM : "zoom",
- BRUSH : "brush",
- LASSO : "lasso"
+ MOVE : "move",
+ LASSO : "lasso",
+ EYEDROPPER : "eyedropper",
+ ROTATE : "rotate",
+ BRUSH : "brush",
+ ZOOM : "zoom",
};
export const canUseBrush = ( activeDocument, activeLayer ) => {
diff --git a/src/factories/layer-factory.js b/src/factories/layer-factory.js
index 348dd9a..66fcf90 100644
--- a/src/factories/layer-factory.js
+++ b/src/factories/layer-factory.js
@@ -31,7 +31,7 @@ const LayerFactory = {
create({
name = "New Layer",
type = LAYER_GRAPHIC, bitmap = null, mask = null,
- x = 0, y = 0, maskX = 0, maskY = 0, width = 1, height = 1, visible = true
+ x = 0, y = 0, maskX = 0, maskY = 0, width = 1, height = 1, rotation = 0, visible = true
} = {}) {
return {
id: `layer_${( ++UID_COUNTER )}`,
@@ -45,6 +45,7 @@ const LayerFactory = {
maskY,
width,
height,
+ rotation,
visible,
selection: null, // only used at runtime, will not be serialized
}
@@ -66,6 +67,7 @@ const LayerFactory = {
y2: layer.maskY,
w: layer.width,
h: layer.height,
+ r: layer.rotation,
v: layer.visible,
};
},
@@ -88,6 +90,7 @@ const LayerFactory = {
maskY: layer.y2,
width: layer.w,
height: layer.h,
+ rotation: layer.r,
visible: layer.v
});
}
diff --git a/src/services/keyboard-service.js b/src/services/keyboard-service.js
index 7d1bbf5..6f506c3 100644
--- a/src/services/keyboard-service.js
+++ b/src/services/keyboard-service.js
@@ -229,6 +229,10 @@ function handleKeyDown( event ) {
}
break;
+ case 82: // R
+ setActiveTool( ToolTypes.ROTATE );
+ break;
+
case 83: // S
if ( hasOption ) {
// ...
diff --git a/src/utils/image-math.js b/src/utils/image-math.js
index 68609a7..bf56ca8 100644
--- a/src/utils/image-math.js
+++ b/src/utils/image-math.js
@@ -105,3 +105,21 @@ export const getRectangleForSelection = ( selection ) => {
height : maxY - minY
};
};
+
+export const translatePointerRotation = ( x, y, rotationCenterX, rotationCenterY, angleInRadians ) => {
+ const x2 = x - rotationCenterX;
+ const y2 = y - rotationCenterY;
+
+ const cos = Math.cos( -angleInRadians );
+ const sin = Math.sin( -angleInRadians );
+
+ return {
+ x : x2 * cos - y2 * sin + rotationCenterX,
+ y : x2 * sin + y2 * cos + rotationCenterY
+ };
+};
+
+export const getRotationCenter = ({ left, top, width, height }) => ({
+ tX : left + width * .5,
+ tY : top + height * .5
+});
diff --git a/tests/unit/factories/layer-factory.spec.js b/tests/unit/factories/layer-factory.spec.js
index 5721def..60be680 100644
--- a/tests/unit/factories/layer-factory.spec.js
+++ b/tests/unit/factories/layer-factory.spec.js
@@ -23,6 +23,7 @@ describe( "Layer factory", () => {
maskY: 0,
width: 1,
height: 1,
+ rotation: 0,
visible: true,
selection: null,
});
@@ -40,6 +41,7 @@ describe( "Layer factory", () => {
maskY: 25,
width: 16,
height: 9,
+ rotation: 270,
visible: false,
});
expect( layer ).toEqual({
@@ -54,6 +56,7 @@ describe( "Layer factory", () => {
maskY: 25,
width: 16,
height: 9,
+ rotation: 270,
visible: false,
selection: null,
})
@@ -70,6 +73,7 @@ describe( "Layer factory", () => {
y: 50,
width: 16,
height: 9,
+ rotation: -90,
visible: false,
});