Made zooming consistent with ideal document scale for window size. Added keyboard shortcut for zoom

This commit is contained in:
Igor Zinken
2020-12-19 12:47:33 +01:00
parent ffb92f00fd
commit 5c6b203f9c
18 changed files with 257 additions and 61 deletions

View File

@@ -50,15 +50,16 @@ npm run lint
# TODO / Roadmap
* Unit tests for factories
* Zoom set original size isn't that accurate (check also on mobile views)
* DrawableLayer should only draw when brush tool is active
* Canvas clearRect() is not doing full width and height ? (might be related to drawable layer click color problem)
* Document resizing doesn't rescale sprites properly?
* Layer masking should be linked to a target layer
* Layer view in options-panel: allow naming, repositioning, toggle visibility, change type (for masking), opacity
* Canvas util : store transparency of images
* Restored base64 images should be treated as binary once more (see layer-factory)
* scale logic should move from zoomable-canvas into zCanvas (as handleInteraction needs to transform offsets by zoom ratio, see DrawableLayer!)
* adjust scaling (on widescreen images scale in the width, rather than go for full height and zoomed out mode)
* Zoom level 1 should be equal to the last ratio based rescale
* Image position must be made persistent (now isn't on document switch)
* Implement selections
* Unload Blobs when images are no longer used in document (see sprite-factory disposeSprite, keep instance count of usages)

View File

@@ -45,20 +45,30 @@
import { mapState, mapGetters, mapMutations, mapActions } from "vuex";
import ZoomableCanvas from "@/components/ui/zcanvas/zoomable-canvas";
import DrawableLayer from "@/components/ui/zcanvas/drawable-layer";
import { scaleToRatio } from "@/utils/image-math";
import { MAX_ZOOM } from "@/definitions/tool-types";
import { scaleToRatio, scaleValue, isPortrait } from "@/utils/image-math";
import {
createSpriteForLayer, runSpriteFn, flushLayerSprites, flushCache,
} from "@/factories/sprite-factory";
/* internal non-reactive properties */
let lastDocument;
let lastDocument, containerSize;
// maintain a pool of sprites representing the layers within the active document
// the sprites themselves are cached within the sprite-factory, this is merely
// used for change detection in the current editing session (see watchers)
const layerPool = new Map();
// scale of the on-screen canvas relative to the document
let xScale = 1, yScale = 1, zoom = 1, containerSize;
let xScale = 1, yScale = 1, zoom = 1, maxScale = 1;
const MAX_IMAGE_SIZE = 8000; // in pixels, this determines the max zoom in factor
const calculateMaxScale = ( width, height ) => {
if ( isPortrait( width, height )) {
maxScale = MAX_IMAGE_SIZE / height * 100 / MAX_ZOOM;
} else {
maxScale = MAX_IMAGE_SIZE / width * 100 / MAX_ZOOM;
}
};
export default {
data: () => ({
@@ -67,21 +77,21 @@ export default {
}),
computed: {
...mapState([
"zCanvas",
"windowSize"
"windowSize",
]),
...mapGetters([
"zCanvas",
"activeDocument",
"layers",
"activeLayer",
"activeTool",
"zoomOptions",
"zCanvasBaseDimensions",
]),
},
watch: {
windowSize() {
this.cacheContainerSize();
this.scaleCanvas();
this.calcIdealDimensions();
},
activeDocument: {
handler( document, oldValue = null ) {
@@ -93,14 +103,6 @@ export default {
}
return;
}
if ( !this.zCanvas ) {
this.createCanvas();
this.$nextTick(() => {
this.zCanvas.insertInPage( this.$refs.canvasContainer );
this.cacheContainerSize();
this.scaleCanvas();
});
}
const { id, width, height } = document;
// switching between documents
if ( id !== lastDocument ) {
@@ -108,7 +110,16 @@ export default {
flushCache();
layerPool.clear();
}
if ( this.zCanvas.width !== width || this.zCanvas.height !== height ) {
if ( !this.zCanvas ) {
this.createCanvas();
this.$nextTick(() => {
this.zCanvas.insertInPage( this.$refs.canvasContainer );
this.cacheContainerSize();
this.scaleCanvas();
});
calculateMaxScale( width, height );
} else if ( this.zCanvas.width !== width || this.zCanvas.height !== height ) {
calculateMaxScale( width, height );
this.scaleCanvas();
}
},
@@ -140,7 +151,9 @@ export default {
return;
}
const { id } = layer;
[ ...layerPool.entries() ].forEach(([ key, sprite ]) => sprite.setInteractive( key === id ));
[ ...layerPool.entries() ].forEach(([ key, sprite ]) => {
sprite.setInteractive( key === id )
});
},
},
activeTool( tool ) {
@@ -159,7 +172,12 @@ export default {
zoomOptions: {
deep: true,
handler({ level }) {
zoom = level;
// are we zooming in or out (relative from the base, not necessarily the previous value)
if ( level > 0 ) {
zoom = scaleValue( level, MAX_ZOOM, maxScale - 1 ) + 1;
} else {
zoom = 1 - scaleValue( Math.abs( level ), MAX_ZOOM, 1 - ( 1 / maxScale ));
}
// cache the current scroll offset so we can zoom from the current offset
// note that by default we zoom from the center (when document was unscrolled)
@@ -167,7 +185,7 @@ export default {
const ratioX = Math.round( scrollLeft / scrollWidth ) || .5;
const ratioY = Math.round( scrollTop / scrollHeight ) || .5;
// rescale canvas, note we can omit the ratio check as the ratio will remain the same
// rescale canvas, note we omit the best fit calculation as we zoom from the calculated base
this.scaleCanvas( false );
// maintain relative scroll offset after rescale
@@ -184,6 +202,7 @@ export default {
methods: {
...mapMutations([
"setZCanvas",
"setZCanvasBaseDimensions",
]),
...mapActions([
"requestDocumentClose",
@@ -202,25 +221,37 @@ export default {
containerSize = this.$el.parentNode?.getBoundingClientRect();
},
/**
* Ensures the canvas fills out the available space while also maintaining
* the ratio of the document is is representing.
* Scales the canvas to dimensions corresponding to document size and zoom.
* When calculateBestFit is true, this ensures the canvas fills out the available
* space while also maintaining the ratio of the document is is representing.
* This resulting value is used as the baseline for the unzoomed level. This
* should be recalculated on window resize.
*/
scaleCanvas( performRatioScale = true ) {
scaleCanvas( calculateBestFit = true ) {
if ( !this.activeDocument ) {
return;
}
let { width, height } = this.activeDocument;
if ( performRatioScale ) {
({ width, height } = scaleToRatio( width, height, containerSize.width, containerSize.height ));
if ( calculateBestFit ) {
const { width, height } = this.activeDocument;
const scaledSize = scaleToRatio( width, height, containerSize.width, containerSize.height );
this.setZCanvasBaseDimensions( scaledSize );
xScale = scaledSize.width / this.activeDocument.width;
yScale = scaledSize.height / this.activeDocument.height;
}
this.wrapperHeight = `${window.innerHeight - containerSize.top - 20}px`;
this.zCanvas.setDimensions( width * zoom, height * zoom, true, true ); // replace to not multiply by zoom
xScale = width / this.activeDocument.width;
yScale = height / this.activeDocument.height;
// replace below by updated zCanvas lib to not multiply by zoom
this.zCanvas.setDimensions(
this.zCanvasBaseDimensions.width * zoom,
this.zCanvasBaseDimensions.height * zoom,
true, true
);
this.zCanvas.setZoomFactor( xScale * zoom, yScale * zoom ); // replace with zCanvas.setZoom()
this.centerCanvas = this.zCanvas.getWidth() < containerSize.width || this.zCanvas.getHeight() < containerSize.height ;
},
calcIdealDimensions() {
this.cacheContainerSize();
this.scaleCanvas();
},
},
};
</script>

View File

@@ -26,7 +26,7 @@
<h2 v-t="'resizeDocument'"></h2>
</template>
<template #content>
<div class="form">
<div class="form" @keyup.enter="save()">
<div class="wrapper input">
<label v-t="'width'"></label>
<input

View File

@@ -71,7 +71,7 @@
</template>
<script>
import { mapState, mapGetters, mapMutations } from "vuex";
import { mapGetters, mapMutations } from "vuex";
import Modal from "@/components/modal/modal";
import SelectBox from '@/components/ui/select-box/select-box';
import Slider from "@/components/ui/slider/slider";
@@ -94,10 +94,8 @@ export default {
quality: 95,
}),
computed: {
...mapState([
"zCanvas",
]),
...mapGetters([
"zCanvas",
"activeDocument",
]),
fileTypes() {

View File

@@ -1,5 +1,7 @@
{
"en-US": {
"zoomLevel": "Zoom level"
"zoomLevel": "Zoom level",
"bestFit": "Best fit",
"original": "Original"
}
}

View File

@@ -29,39 +29,54 @@
:max="max"
:tooltip="'none'"
/>
<div class="actions">
<button
v-t="'bestFit'"
type="button"
class="button button--small"
@click="setBestFit()"
></button>
<button
v-t="'original'"
type="button"
class="button button--small"
@click="setOriginalSize()"
></button>
</div>
</div>
</template>
<script>
import { mapGetters, mapMutations } from "vuex";
import ToolTypes, { MIN_ZOOM, MAX_ZOOM } from "@/definitions/tool-types";
import Slider from "@/components/ui/slider/slider";
import ToolTypes from "@/definitions/tool-types";
import messages from "./messages.json";
const MAX_ZOOM = 3;
export default {
i18n: { messages },
components: {
Slider,
},
data: () => ({
min: 1,
max: 10,
min: MIN_ZOOM,
max: MAX_ZOOM,
}),
computed: {
...mapGetters([
"activeDocument",
"zoomOptions",
"zCanvas",
"zCanvasBaseDimensions",
]),
zoomLevel: {
get() {
return ( this.zoomOptions.level / MAX_ZOOM ) * this.max;
return this.zoomOptions.level;
},
set( value ) {
this.setToolOptionValue({
tool: ToolTypes.ZOOM,
option: "level",
value: ( value / this.max ) * MAX_ZOOM
value
});
}
}
@@ -70,10 +85,25 @@ export default {
...mapMutations([
"setToolOptionValue",
]),
setBestFit() {
this.zoomLevel = 0;
},
setOriginalSize() {
this.zoomLevel = ( this.activeDocument.width / this.zCanvasBaseDimensions.width ) * window.devicePixelRatio;
},
},
};
</script>
<style lang="scss" scoped>
@import "@/styles/tool-option";
.actions {
display: flex;
margin-top: $spacing-medium;
button {
flex: 1;
margin: 0 $spacing-small;
}
}
</style>

View File

@@ -28,8 +28,8 @@
<script>
import "@simonwep/pickr/dist/themes/nano.min.css";
import Pickr from "@simonwep/pickr/dist/pickr.es5.min"; // 3 x size of modern bundle
//import Pickr from "@simonwep/pickr";
//import Pickr from "@simonwep/pickr/dist/pickr.es5.min"; // 3 x size of modern bundle
import Pickr from "@simonwep/pickr";
let pickrInstance;

View File

@@ -27,3 +27,5 @@ export default {
};
export const MAX_BRUSH_SIZE = 100;
export const MIN_ZOOM = -50; // zooming out from base (which is 0)
export const MAX_ZOOM = 50; // zooming in from base (which is 0)

View File

@@ -72,6 +72,7 @@ import Toolbox from "@/components/toolbox/toolbox";
import DialogWindow from "@/components/dialog-window/dialog-window";
import Notifications from '@/components/notifications/notifications';
import { isMobile } from "@/utils/environment-util";
import ToolTypes from "@/definitions/tool-types";
import store from "./store";
import messages from "./messages.json";
import {
@@ -149,12 +150,15 @@ export default {
"setWindowSize",
"closeModal",
"setToolboxOpened",
"setToolOptionValue",
]),
...mapActions([
"setupServices",
]),
handleResize() {
this.setWindowSize({ width: window.innerWidth, height: window.innerHeight });
// prevent maximum zoom at previous small window size to lead to excessively large document canvas
this.setToolOptionValue({ tool: ToolTypes.ZOOM, option: "level", value: 1 });
},
/**
* Ensure the document container has optimal size. Ideally we'd like a pure
@@ -168,7 +172,7 @@ export default {
const toolboxWidth = this.$refs.toolbox?.$el.clientWidth;
const optionsPanelWidth = this.$refs.optionsPanel?.$el.clientWidth;
this.docWidth = `calc(100% - ${toolboxWidth + optionsPanelWidth + 32}px)`;
this.$nextTick(() => this.$refs.documentCanvas?.scaleCanvas());
this.$nextTick(() => this.$refs.documentCanvas?.calcIdealDimensions());
},
}
};

View File

@@ -21,7 +21,7 @@
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
import { ADD_LAYER } from "@/definitions/modal-windows";
import ToolTypes, { MAX_BRUSH_SIZE } from "@/definitions/tool-types";
import ToolTypes, { MAX_BRUSH_SIZE, MIN_ZOOM, MAX_ZOOM } from "@/definitions/tool-types";
let state, getters, commit, dispatch, listener,
suspended = false, blockDefaults = true, optionDown = false, shiftDown = false;
@@ -265,15 +265,27 @@ function handleKeyDown( event ) {
}
break;
case 187: // +
commit( "setToolOptionValue",
{ tool: ToolTypes.ZOOM, option: "level", value: Math.min( MAX_ZOOM, getters.zoomOptions.level + ( MAX_ZOOM / 10 ))
});
break;
case 189: // -
commit( "setToolOptionValue",
{ tool: ToolTypes.ZOOM, option: "level", value: Math.max( MIN_ZOOM, getters.zoomOptions.level - ( MAX_ZOOM / 10 ))
});
break;
case 219: // [
commit( "setToolOptionValue",
{ tool: ToolTypes.BRUSH, option: "size", value: Math.max( 1, getters.brushOptions.size - 1 )
{ tool: ToolTypes.BRUSH, option: "size", value: Math.max( 1, getters.brushOptions.size - 5 )
});
break;
case 221: // ]
commit( "setToolOptionValue", {
tool: ToolTypes.BRUSH, option: "size", value: Math.min( MAX_BRUSH_SIZE, getters.brushOptions.size + 1 )
tool: ToolTypes.BRUSH, option: "size", value: Math.min( MAX_BRUSH_SIZE, getters.brushOptions.size + 5 )
});
break;
}

View File

@@ -23,6 +23,7 @@
import LZString from "lz-string";
import KeyboardService from "@/services/keyboard-service";
import DocumentFactory from "@/factories/document-factory";
import canvasModule from "./modules/canvas-module";
import documentModule from "./modules/document-module";
import imageModule from "./modules/image-module";
import toolModule from "./modules/tool-module";
@@ -39,6 +40,7 @@ const translate = ( key, optArgs ) => i18n?.t( key, optArgs ) ?? key;
export default {
modules: {
canvasModule,
documentModule,
imageModule,
toolModule,
@@ -51,7 +53,6 @@ export default {
dialog: null, // currently opened dialog
modal: null, // currently opened modal
notifications: [], // notification message queue
zCanvas: null, // zCanvas instance
windowSize: {
width: window.innerWidth,
height: window.innerHeight
@@ -105,9 +106,6 @@ export default {
clearNotifications( state ) {
state.notifications = [];
},
setZCanvas( state, zCanvas ) {
state.zCanvas = zCanvas;
},
/**
* cache the resize in the store so components can react to these values
* instead of maintaining multiple listeners at the expense of DOM trashing/performance hits

View File

@@ -0,0 +1,48 @@
/**
* 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.
*/
import Vue from "vue";
export default {
state: {
zCanvas: null, // zCanvas instance
// the base dimensions describe the "best fit" scale to represent
// the currently active document at the current window size, this
// is basically the base line used for the unzoomed document view
zCanvasBaseDimensions: {
width: 0,
height: 0,
},
},
getters: {
zCanvas: state => state.zCanvas,
zCanvasBaseDimensions: state => state.zCanvasBaseDimensions,
},
mutations: {
setZCanvas( state, zCanvas ) {
state.zCanvas = zCanvas;
},
setZCanvasBaseDimensions( state, { width, height }) {
Vue.set( state, "zCanvasBaseDimensions", { width, height });
},
},
};

View File

@@ -24,7 +24,7 @@ import Vue from "vue";
import DocumentFactory from "@/factories/document-factory";
import LayerFactory from "@/factories/layer-factory";
import { flushLayerSprites } from "@/factories/sprite-factory";
import { flushLayerSprites, runSpriteFn } from "@/factories/sprite-factory";
export default {
state: {
@@ -45,8 +45,16 @@ export default {
},
setActiveDocumentSize( state, { width, height }) {
const document = state.documents[ state.activeIndex ];
const ratio = width / document.width;
document.width = width;
document.height = height;
document.layers?.forEach( layer => {
layer.width = width;
layer.height = height;
});
runSpriteFn( sprite => {
sprite.setBounds( sprite.getX() * ratio, sprite.getY() * ratio, width, height );
});
},
addNewDocument( state, nameOrDocument ) {
const document = typeof nameOrDocument === "object" ? nameOrDocument : DocumentFactory.create({ name: nameOrDocument });

View File

@@ -53,6 +53,15 @@ export const scaleToRatio = ( imageWidth, imageHeight, destWidth, destHeight ) =
};
};
/**
* convenience method to scale given value and its expected maxValue against
* an arbitrary range (defined by maxCompareValue in relation to maxValue)
*/
export const scaleValue = ( value, maxValue, maxCompareValue ) => {
const ratio = maxCompareValue / maxValue;
return Math.min( maxValue, value ) * ratio;
};
export const isPortrait = ( width, height ) => width < height;
export const isLandscape = ( width, height ) => width > height;
export const isSquare = ( width, height ) => width === height;

View File

@@ -0,0 +1,32 @@
import storeModule from "@/store/modules/canvas-module";
const { getters, mutations } = storeModule;
describe( "Vuex canvas module", () => {
describe( "getters", () => {
it( "should be able to retrieve the current zCanvas instance", () => {
const state = { zCanvas: { foo: "bar" } };
expect( getters.zCanvas( state )).toEqual( state.zCanvas );
});
it( "should be able to retrieve the zCanvas instance base dimensions", () => {
const state = { zCanvasBaseDimensions: { width: 10, height: 5 } };
expect( getters.zCanvasBaseDimensions( state )).toEqual( state.zCanvasBaseDimensions );
});
});
describe( "mutations", () => {
it( "should be able to set the current zCanvas instance", () => {
const state = { zCanvas: null };
const canvas = { foo: "bar" };
mutations.setZCanvas( state, canvas );
expect( state.zCanvas ).toEqual( canvas );
});
it( "should be able to set the zCanvas instance base dimensions", () => {
const state = { zCanvasBaseDimensions: { width: 10, height: 5 } };
mutations.setZCanvasBaseDimensions( state, { width: 50, height: 40 } );
expect( state.zCanvasBaseDimensions ).toEqual({ width: 50, height: 40 });
});
});
});

View File

@@ -6,6 +6,7 @@ const { getters, mutations } = DocumentModule;
let mockUpdateFn;
jest.mock( "@/factories/sprite-factory", () => ({
flushLayerSprites: (...args) => mockUpdateFn?.( "flushLayerSprites", ...args ),
runSpriteFn: (...args) => mockUpdateFn?.( "runSpriteFn", ...args ),
}));
jest.mock( "@/factories/layer-factory", () => ({
create: (...args) => mockUpdateFn?.( "create", ...args ),

View File

@@ -143,13 +143,6 @@ describe( "Vuex store", () => {
});
});
it( "should be able to set the current zCanvas instance", () => {
const state = { zCanvas: null };
const canvas = { foo: "bar" };
mutations.setZCanvas( state, canvas );
expect( state.zCanvas ).toEqual( canvas );
});
it( "should be able to set the window size", () => {
const state = { windowSize: { width: 0, height: 0 }};
const width = 500;

View File

@@ -0,0 +1,27 @@
import { scaleToRatio, isPortrait, isLandscape, isSquare } from "@/utils/image-math";
describe( "Image math utilities", () => {
describe( "When determining ratios", () => {
const PORTRAIT = { width: 3, height: 4 };
const LANDSCAPE = { width: 4, height: 3 };
const SQUARE = { width: 3, height: 3 };
it( "should correctly identify portrait images", () => {
expect( isPortrait( PORTRAIT.width, PORTRAIT.height )).toBe( true );
expect( isPortrait( LANDSCAPE.width, LANDSCAPE.height )).toBe( false );
expect( isPortrait( SQUARE.width, SQUARE.height )).toBe( false );
});
it( "should correctly identify landscape images", () => {
expect( isLandscape( PORTRAIT.width, PORTRAIT.height )).toBe( false );
expect( isLandscape( LANDSCAPE.width, LANDSCAPE.height )).toBe( true );
expect( isLandscape( SQUARE.width, SQUARE.height )).toBe( false );
});
it( "should correctly identify square images", () => {
expect( isSquare( PORTRAIT.width, PORTRAIT.height )).toBe( false );
expect( isSquare( LANDSCAPE.width, LANDSCAPE.height )).toBe( false );
expect( isSquare( SQUARE.width, SQUARE.height )).toBe( true );
});
});
});