mirror of
https://github.com/igorski/bitmappery.git
synced 2026-06-16 19:25:38 +02:00
Implemented layer scaling
This commit is contained in:
@@ -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!)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"en-US": {
|
||||
"rotation": "Rotation"
|
||||
"rotation": "Rotation",
|
||||
"reset": "Reset"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'"
|
||||
/>
|
||||
<div class="actions">
|
||||
<button
|
||||
v-t="'reset'"
|
||||
type="button"
|
||||
class="button button--small"
|
||||
@click="reset()"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -82,7 +90,10 @@ export default {
|
||||
commit();
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
reset() {
|
||||
this.rotation = 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"en-US": {
|
||||
"scale": "Scale",
|
||||
"reset": "Reset"
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
<template>
|
||||
<div class="tool-option">
|
||||
<h3 v-t="'scale'"></h3>
|
||||
<slider
|
||||
v-model="scale"
|
||||
:min="min"
|
||||
:max="max"
|
||||
:tooltip="'none'"
|
||||
/>
|
||||
<div class="actions">
|
||||
<button
|
||||
v-t="'reset'"
|
||||
type="button"
|
||||
class="button button--small"
|
||||
@click="reset()"
|
||||
></button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from "vuex";
|
||||
import ToolTypes, { MIN_ZOOM, MAX_ZOOM } from "@/definitions/tool-types";
|
||||
import Slider from "@/components/ui/slider/slider";
|
||||
import { enqueueState } from "@/factories/history-state-factory";
|
||||
import { scale } from "@/math/unit-math";
|
||||
import messages from "./messages.json";
|
||||
|
||||
export default {
|
||||
i18n: { messages },
|
||||
components: {
|
||||
Slider,
|
||||
},
|
||||
data: () => ({
|
||||
min: MIN_ZOOM,
|
||||
max: MAX_ZOOM,
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters([
|
||||
"activeLayerIndex",
|
||||
"activeLayerEffects",
|
||||
]),
|
||||
scale: {
|
||||
get() {
|
||||
return ( this.activeLayerEffects.scale - 1 ) * MAX_ZOOM;
|
||||
},
|
||||
set( value ) {
|
||||
this.update( scale( value, MAX_ZOOM, 1 ) + 1 );
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
update( scale ) {
|
||||
const oldScale = this.activeLayerEffects.scale;
|
||||
const index = this.activeLayerIndex;
|
||||
const store = this.$store;
|
||||
const commit = () => {
|
||||
store.commit( "updateLayerEffects", { index, effects: { scale } });
|
||||
};
|
||||
commit();
|
||||
enqueueState( `scale_${index}`, {
|
||||
undo() {
|
||||
store.commit( "updateLayerEffects", { index, effects: { scale: oldScale } });
|
||||
},
|
||||
redo() {
|
||||
commit();
|
||||
},
|
||||
});
|
||||
},
|
||||
reset() {
|
||||
this.scale = 0;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import "@/styles/options-panel";
|
||||
</style>
|
||||
@@ -15,6 +15,7 @@
|
||||
"brush": "Brush",
|
||||
"eraser": "Eraser",
|
||||
"cloneStamp": "Clone stamp",
|
||||
"scaleLayer": "Scale layer",
|
||||
"text": "Text",
|
||||
"newTextLayer": "New text layer",
|
||||
"color": "Color"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -29,6 +29,7 @@ const ToolTypes = {
|
||||
DRAG : "drag", // drags layer within document
|
||||
LASSO : "lasso",
|
||||
SELECTION : "selection",
|
||||
SCALE : "scale",
|
||||
EYEDROPPER : "eyedropper",
|
||||
ROTATE : "rotate",
|
||||
FILL : "fill",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -231,6 +231,8 @@ function handleKeyDown( event ) {
|
||||
if ( hasOption ) {
|
||||
dispatch( "clearSelection" );
|
||||
preventDefault( event ); // bookmark
|
||||
} else if ( getters.activeLayer ) {
|
||||
setActiveTool( ToolTypes.SCALE );
|
||||
}
|
||||
break;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 );
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user