diff --git a/README.md b/README.md index 2d3f95a..5e2483f 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ further benchmarking and tweaking. # TODO / Roadmap +* Implement layer sorting and opacity * Layer source and mask must not be stored as Vue observables * Implement action queue when drawing, only execute drawing on zCanvas.sprite.update()-hook * Maintain cache for source images at the display destination size (invalidate on window resize / zoom), this prevents processing large images that are never displayed at their full scale @@ -150,7 +151,5 @@ further benchmarking and tweaking. * Restored base64 images should be treated as binary once more (see layer-factory) * 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) -* Implement layer sorting and opacity -* Implement layer scaling * Implement merged layer selection * Scale logic should move from zoomable-canvas into zCanvas (as handleInteraction needs to transform offsets by zoom ratio, see LayerSprite!) diff --git a/src/components/options-panel/options-panel.vue b/src/components/options-panel/options-panel.vue index ddf98fe..8a13921 100644 --- a/src/components/options-panel/options-panel.vue +++ b/src/components/options-panel/options-panel.vue @@ -86,6 +86,8 @@ export default { return () => import( "./tool-options-clone/tool-options-clone" ); case ToolTypes.ROTATE: return () => import( "./tool-options-rotate/tool-options-rotate" ); + case ToolTypes.SCALE: + return () => import( "./tool-options-scale/tool-options-scale" ); case ToolTypes.MIRROR: return () => import( "./tool-options-mirror/tool-options-mirror" ); case ToolTypes.TEXT: diff --git a/src/components/options-panel/tool-options-rotate/messages.json b/src/components/options-panel/tool-options-rotate/messages.json index 14f0479..a133fdc 100644 --- a/src/components/options-panel/tool-options-rotate/messages.json +++ b/src/components/options-panel/tool-options-rotate/messages.json @@ -1,5 +1,6 @@ { "en-US": { - "rotation": "Rotation" + "rotation": "Rotation", + "reset": "Reset" } } diff --git a/src/components/options-panel/tool-options-rotate/tool-options-rotate.vue b/src/components/options-panel/tool-options-rotate/tool-options-rotate.vue index 83d1fe3..81b9e62 100644 --- a/src/components/options-panel/tool-options-rotate/tool-options-rotate.vue +++ b/src/components/options-panel/tool-options-rotate/tool-options-rotate.vue @@ -1,7 +1,7 @@ /** * The MIT License (MIT) * - * Igor Zinken 2020 - https://www.igorski.nl + * Igor Zinken 2020-2021 - 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 @@ -29,6 +29,14 @@ :max="max" :tooltip="'none'" /> +
+ +
@@ -82,7 +90,10 @@ export default { commit(); }, }); - } + }, + reset() { + this.rotation = 0; + }, }, }; diff --git a/src/components/options-panel/tool-options-scale/messages.json b/src/components/options-panel/tool-options-scale/messages.json new file mode 100644 index 0000000..62b74de --- /dev/null +++ b/src/components/options-panel/tool-options-scale/messages.json @@ -0,0 +1,6 @@ +{ + "en-US": { + "scale": "Scale", + "reset": "Reset" + } +} diff --git a/src/components/options-panel/tool-options-scale/tool-options-scale.vue b/src/components/options-panel/tool-options-scale/tool-options-scale.vue new file mode 100644 index 0000000..58134fe --- /dev/null +++ b/src/components/options-panel/tool-options-scale/tool-options-scale.vue @@ -0,0 +1,101 @@ +/** + * The MIT License (MIT) + * + * Igor Zinken 2021 - 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/toolbox/messages.json b/src/components/toolbox/messages.json index 38bbf87..2fa3c4c 100644 --- a/src/components/toolbox/messages.json +++ b/src/components/toolbox/messages.json @@ -15,6 +15,7 @@ "brush": "Brush", "eraser": "Eraser", "cloneStamp": "Clone stamp", + "scaleLayer": "Scale layer", "text": "Text", "newTextLayer": "New text layer", "color": "Color" diff --git a/src/components/toolbox/toolbox.vue b/src/components/toolbox/toolbox.vue index b6e9e99..731b2ae 100644 --- a/src/components/toolbox/toolbox.vue +++ b/src/components/toolbox/toolbox.vue @@ -167,6 +167,11 @@ export default { i18n: "cloneStamp", icon: "stamp", key: "S", disabled: !clonable, hasOptions: true }, + { + type: ToolTypes.SCALE, + i18n: "scaleLayer", icon: "resize", key: "D", + disabled: !this.activeLayer, hasOptions: true, + }, { type: ToolTypes.EYEDROPPER, i18n: "eyedropper", icon: "eyedropper", key: "I", diff --git a/src/components/ui/zcanvas/layer-sprite.js b/src/components/ui/zcanvas/layer-sprite.js index 0729543..8101f23 100644 --- a/src/components/ui/zcanvas/layer-sprite.js +++ b/src/components/ui/zcanvas/layer-sprite.js @@ -25,7 +25,7 @@ import { sprite } from "zcanvas" import { createCanvas, cloneCanvas, resizeImage, globalToLocal } from "@/utils/canvas-util"; import { renderCross, renderMasked } from "@/utils/render-util"; import { LAYER_GRAPHIC, LAYER_MASK, LAYER_TEXT } from "@/definitions/layer-types"; -import { translatePointerRotation, rotatePoints } from "@/math/image-math"; +import { translatePointerRotation, rotatePoints, scaleRectangle } from "@/math/image-math"; import { getRectangleForSelection, isSelectionClosed } from "@/math/selection-math"; import { renderEffectsForLayer } from "@/services/render-service"; import { flushLayerCache, clearCacheProperty } from "@/services/caches/bitmap-cache"; @@ -87,6 +87,10 @@ class LayerSprite extends sprite { return ( this.layer.effects.rotation % 360 ) !== 0; } + isScaled() { + return this.layer.effects.scale !== 1; + } + cacheBrush( color, radius = 30 ) { this._radius = radius; @@ -185,7 +189,7 @@ class LayerSprite extends sprite { this.forceMoveListener(); this.setDraggable( true ); this._isPaintMode = true; - this.cacheBrush( this.canvas.store.getters.activeColor, toolOptions.size ); + this.cacheBrush( this.canvas.store.getters.activeColor, toolOptions?.size ); // drawable tools can work alongside an existing selection const selection = activeDocument.selection; @@ -348,6 +352,7 @@ class LayerSprite extends sprite { if ( width === 0 || height === 0 ) { ({ width, height } = bounds ); } + // commit change super.setBounds( x, y, width, height ); @@ -451,6 +456,22 @@ class LayerSprite extends sprite { } draw( documentContext, viewport ) { + const scaleDocument = this.isScaled(); + + // in case Layer has scale effect, apply it here (we don't resample the + // actual Layer source to make this behaviour non-destructive, it's + // merely a visualization and thus renderer affair) + + if ( scaleDocument ) { + const { scale } = this.layer.effects; + const { left, top, width, height } = this._bounds; + const xTranslation = ( left + width * 0.5 ) - viewport.left; + const yTranslation = ( top + height * 0.5 ) - viewport.top; + documentContext.save(); + documentContext.translate( xTranslation, yTranslation ); + documentContext.scale( scale, scale ); + documentContext.translate( -xTranslation, -yTranslation ); + } // invoke base class behaviour to render bitmap super.draw( documentContext, viewport ); @@ -484,6 +505,7 @@ class LayerSprite extends sprite { // interactive state implies the sprite's Layer is currently active // show a border around the Layer contents to indicate the active area + if ( this._interactive ) { documentContext.save(); documentContext.lineWidth = 1 / this.canvas.zoomFactor; @@ -501,6 +523,10 @@ class LayerSprite extends sprite { documentContext.strokeRect( destX, destY, width, height ); documentContext.restore(); } + + if ( scaleDocument ) { + documentContext.restore(); + } } dispose() { @@ -517,6 +543,13 @@ export default LayerSprite; /* internal non-instance methods */ +function scaleViewport( viewport, scale ) { + const scaled = scaleRectangle( viewport, scale ); + viewport.right = viewport.left + viewport.width; + viewport.bottom = viewport.top + viewport.height; + return scaled; +} + // NOTE we use getSpriteForLayer() instead of passing the Sprite by reference // as it is possible the Sprite originally rendering the Layer has been disposed // and a new one has been created while traversing the change history diff --git a/src/definitions/tool-types.js b/src/definitions/tool-types.js index 99f192e..d99923d 100644 --- a/src/definitions/tool-types.js +++ b/src/definitions/tool-types.js @@ -29,6 +29,7 @@ const ToolTypes = { DRAG : "drag", // drags layer within document LASSO : "lasso", SELECTION : "selection", + SCALE : "scale", EYEDROPPER : "eyedropper", ROTATE : "rotate", FILL : "fill", diff --git a/src/factories/effects-factory.js b/src/factories/effects-factory.js index 8c48159..c81ac78 100644 --- a/src/factories/effects-factory.js +++ b/src/factories/effects-factory.js @@ -21,8 +21,9 @@ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ const EffectFactory = { - create({ rotation = 0, mirrorX = false, mirrorY = false } = {}) { + create({ scale = 1, rotation = 0, mirrorX = false, mirrorY = false } = {}) { return { + scale, rotation, mirrorX, mirrorY, @@ -35,6 +36,7 @@ const EffectFactory = { */ serialize( effects ) { return { + s: effects.scale, r: effects.rotation, x: effects.mirrorX, y: effects.mirrorY, @@ -47,16 +49,18 @@ const EffectFactory = { */ deserialize( effects = {} ) { return EffectFactory.create({ - rotation: effects.r, - mirrorX: effects.x, - mirrorY: effects.y, + scale : effects.s, + rotation : effects.r, + mirrorX : effects.x, + mirrorY : effects.y, }); } }; export default EffectFactory; export const isEqual = ( effects, effectsToCompare = {} ) => { - return effects.rotation === effectsToCompare.rotation && - effects.mirrorX === effectsToCompare.mirrorX && + return effects.scale === effectsToCompare.scale && + effects.rotation === effectsToCompare.rotation && + effects.mirrorX === effectsToCompare.mirrorX && effects.mirrorY === effectsToCompare.mirrorY; }; diff --git a/src/math/image-math.js b/src/math/image-math.js index bff8d1a..b5d2f68 100644 --- a/src/math/image-math.js +++ b/src/math/image-math.js @@ -169,3 +169,14 @@ export const rotatePoints = ( coordinateList, rotationCenterX, rotationCenterY, return translatePointerRotation( x, y, rotationCenterX, rotationCenterY, angleInRadians ); }); }; + +export const scaleRectangle = ({ left, top, width, height }, scale = 1 ) => { + const scaledWidth = width * scale; + const scaledHeight = height * scale; + return { + left : left - ( scaledWidth * .5 - width * .5 ), + top : top - ( scaledHeight * .5 - height * .5 ), + width : scaledWidth, + height : scaledHeight + }; +}; diff --git a/src/math/unit-math.js b/src/math/unit-math.js index 6104c4d..e4d12ca 100644 --- a/src/math/unit-math.js +++ b/src/math/unit-math.js @@ -29,3 +29,11 @@ export const pixelsToMm = ( pixels, dpi = 72 ) => pixelsToInch( pixels, dpi export const inchesToPixels = ( inches, dpi = 72 ) => inches * dpi; export const cmToPixels = ( cms, dpi = 72 ) => inchesToPixels( cms / CM_PER_INCH ); export const mmToPixels = ( mms, dpi = 72 ) => inchesToPixels( mms / MM_PER_INCH ); + +// convenience method to scale given value and its expected maxValue against +// an arbitrary range (defined by maxCompareValue in relation to maxValue) + +export const scale = ( value, maxValue, maxCompareValue ) => { + const ratio = maxCompareValue / maxValue; + return Math.min( maxValue, value ) * ratio; +}; diff --git a/src/services/keyboard-service.js b/src/services/keyboard-service.js index 80f65a5..ae569c4 100644 --- a/src/services/keyboard-service.js +++ b/src/services/keyboard-service.js @@ -231,6 +231,8 @@ function handleKeyDown( event ) { if ( hasOption ) { dispatch( "clearSelection" ); preventDefault( event ); // bookmark + } else if ( getters.activeLayer ) { + setActiveTool( ToolTypes.SCALE ); } break; diff --git a/src/styles/options-panel.scss b/src/styles/options-panel.scss index b636026..d7d98b0 100644 --- a/src/styles/options-panel.scss +++ b/src/styles/options-panel.scss @@ -1,10 +1,9 @@ @import "_mixins"; .tool-option { - border-top: 1px dashed $color-lines; border-bottom: 1px dashed $color-lines; - margin: $spacing-medium 0; - padding: $spacing-small 0; + margin: 0 0 $spacing-medium 0; + padding: 0 0 $spacing-medium 0; h3 { color: #FFF; diff --git a/tests/unit/factories/effects-factory.spec.js b/tests/unit/factories/effects-factory.spec.js index df04c29..11e451d 100644 --- a/tests/unit/factories/effects-factory.spec.js +++ b/tests/unit/factories/effects-factory.spec.js @@ -7,7 +7,8 @@ describe( "Effects factory", () => { expect( effects ).toEqual({ mirrorX: false, mirrorY: false, - rotation: expect.any( Number ) + rotation: expect.any( Number ), + scale: 1 }); }); @@ -15,12 +16,14 @@ describe( "Effects factory", () => { const effects = EffectsFactory.create({ mirrorX: true, mirrorY: true, - rotation: -90 + rotation: -90, + scale: 2 }); expect( effects ).toEqual({ mirrorX: true, mirrorY: true, - rotation: -90 + rotation: -90, + scale: 2 }); }); }); @@ -30,7 +33,8 @@ describe( "Effects factory", () => { const effects = EffectsFactory.create({ mirrorX: true, mirrorY: true, - rotation: 270 + rotation: 270, + scale: .5 }); const serialized = EffectsFactory.serialize( effects ); const deserialized = EffectsFactory.deserialize( serialized ); diff --git a/tests/unit/math/image-math.spec.js b/tests/unit/math/image-math.spec.js index 06d8ed2..11d6bbe 100644 --- a/tests/unit/math/image-math.spec.js +++ b/tests/unit/math/image-math.spec.js @@ -1,5 +1,5 @@ import { - fastRound, scaleToRatio, constrain, isPortrait, isLandscape, isSquare, translatePoints, + fastRound, scaleToRatio, constrain, isPortrait, isLandscape, isSquare, translatePoints, scaleRectangle } from "@/math/image-math"; describe( "Image math utilities", () => { @@ -67,4 +67,36 @@ describe( "Image math utilities", () => { { x: 15, y: 5 }, { x: 20, y: 10 } ]); }); + + describe( "when scaling rectangles", () => { + it( "should be able to scale a rectangle and maintain its relative offset when scaling up", () => { + const rectangle = { + left: 50, + top: 50, + width: 200, + height: 400 + }; + expect( scaleRectangle( rectangle, 2 )).toEqual({ + left: -50, + top: -150, + width: 400, + height: 800 + }); + }); + + it( "should be able to scale a rectangle and maintain its relative offset when scaling down", () => { + const rectangle = { + left: -50, + top: -150, + width: 400, + height: 800 + }; + expect( scaleRectangle( rectangle, 0.5 )).toEqual({ + left: 50, + top: 50, + width: 200, + height: 400 + }); + }); + }); });