From 31593b9d27c9052d045b3057b60b5f89372f4190 Mon Sep 17 00:00:00 2001 From: Igor Zinken Date: Wed, 23 Dec 2020 20:40:41 +0100 Subject: [PATCH] Added initial layer rotation --- README.md | 4 +- .../tool-options-rotate/messages.json | 5 ++ .../tool-options-rotate.vue | 80 +++++++++++++++++++ .../options-panel/options-panel.vue | 9 ++- src/components/toolbox/messages.json | 1 + src/components/toolbox/toolbox.vue | 5 ++ src/components/ui/zcanvas/layer-sprite.js | 19 ++++- src/definitions/tool-types.js | 10 ++- src/factories/layer-factory.js | 5 +- src/services/keyboard-service.js | 4 + src/utils/image-math.js | 18 +++++ tests/unit/factories/layer-factory.spec.js | 4 + 12 files changed, 154 insertions(+), 10 deletions(-) create mode 100644 src/components/options-panel/components/tool-options-rotate/messages.json create mode 100644 src/components/options-panel/components/tool-options-rotate/tool-options-rotate.vue 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, });