Commit 9fee239f authored by Tom Quirk's avatar Tom Quirk Committed by Natalia Tepluhina

Add design_scaler component

This component is intended to be the
UI entry point for scaling/zooming a design.

It emits a @scale event, and holds the increment
and constraint logic for updating scale
parent c60e8aa1
<script> <script>
import _ from 'underscore';
import DesignImage from './image.vue'; import DesignImage from './image.vue';
import DesignOverlay from './design_overlay.vue'; import DesignOverlay from './design_overlay.vue';
...@@ -27,12 +28,24 @@ export default { ...@@ -27,12 +28,24 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
scale: {
type: Number,
required: false,
default: 1,
},
}, },
data() { data() {
return { return {
overlayDimensions: null, overlayDimensions: null,
overlayPosition: null, overlayPosition: null,
currentAnnotationCoordinates: null, currentAnnotationCoordinates: null,
zoomFocalPoint: {
x: 0,
y: 0,
width: 0,
height: 0,
},
initialLoad: true,
}; };
}, },
computed: { computed: {
...@@ -43,6 +56,22 @@ export default { ...@@ -43,6 +56,22 @@ export default {
return (this.isAnnotating && this.currentAnnotationCoordinates) || null; return (this.isAnnotating && this.currentAnnotationCoordinates) || null;
}, },
}, },
beforeDestroy() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return;
presentationViewport.removeEventListener('scroll', this.scrollThrottled, false);
},
mounted() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return;
this.scrollThrottled = _.throttle(() => {
this.shiftZoomFocalPoint();
}, 400);
presentationViewport.addEventListener('scroll', this.scrollThrottled, false);
},
methods: { methods: {
setOverlayDimensions(overlayDimensions) { setOverlayDimensions(overlayDimensions) {
this.overlayDimensions = overlayDimensions; this.overlayDimensions = overlayDimensions;
...@@ -52,8 +81,8 @@ export default { ...@@ -52,8 +81,8 @@ export default {
this.overlayPosition = {}; this.overlayPosition = {};
} }
const { presentationViewport } = this.$refs; const { presentationContainer } = this.$refs;
if (!presentationViewport) return; if (!presentationContainer) return;
// default to center // default to center
this.overlayPosition = { this.overlayPosition = {
...@@ -62,16 +91,88 @@ export default { ...@@ -62,16 +91,88 @@ export default {
}; };
// if the overlay overflows, then don't center // if the overlay overflows, then don't center
if (this.overlayDimensions.width > presentationViewport.offsetWidth) { if (this.overlayDimensions.width > presentationContainer.offsetWidth) {
this.overlayPosition.left = '0'; this.overlayPosition.left = '0';
} }
if (this.overlayDimensions.height > presentationViewport.offsetHeight) { if (this.overlayDimensions.height > presentationContainer.offsetHeight) {
this.overlayPosition.top = '0'; this.overlayPosition.top = '0';
} }
}, },
/**
* Return a point that represents the center of an
* overflowing child element w.r.t it's parent
*/
getViewportCenter() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return {};
// get height of scroll bars (i.e. the max values for scrollTop, scrollLeft)
const scrollBarWidth = presentationViewport.scrollWidth - presentationViewport.offsetWidth;
const scrollBarHeight = presentationViewport.scrollHeight - presentationViewport.offsetHeight;
// determine how many child pixels have been scrolled
const xScrollRatio =
presentationViewport.scrollLeft > 0 ? presentationViewport.scrollLeft / scrollBarWidth : 0;
const yScrollRatio =
presentationViewport.scrollTop > 0 ? presentationViewport.scrollTop / scrollBarHeight : 0;
const xScrollOffset =
(presentationViewport.scrollWidth - presentationViewport.offsetWidth - 0) * xScrollRatio;
const yScrollOffset =
(presentationViewport.scrollHeight - presentationViewport.offsetHeight - 0) * yScrollRatio;
const viewportCenterX = presentationViewport.offsetWidth / 2;
const viewportCenterY = presentationViewport.offsetHeight / 2;
const focalPointX = viewportCenterX + xScrollOffset;
const focalPointY = viewportCenterY + yScrollOffset;
return {
x: focalPointX,
y: focalPointY,
};
},
/**
* Scroll the viewport such that the focal point is positioned centrally
*/
scrollToFocalPoint() {
const { presentationViewport } = this.$refs;
if (!presentationViewport) return;
const scrollX = this.zoomFocalPoint.x - presentationViewport.offsetWidth / 2;
const scrollY = this.zoomFocalPoint.y - presentationViewport.offsetHeight / 2;
presentationViewport.scrollTo(scrollX, scrollY);
},
scaleZoomFocalPoint() {
const { x, y, width, height } = this.zoomFocalPoint;
const widthRatio = this.overlayDimensions.width / width;
const heightRatio = this.overlayDimensions.height / height;
this.zoomFocalPoint = {
x: Math.round(x * widthRatio * 100) / 100,
y: Math.round(y * heightRatio * 100) / 100,
...this.overlayDimensions,
};
},
shiftZoomFocalPoint() {
this.zoomFocalPoint = {
...this.getViewportCenter(),
...this.overlayDimensions,
};
},
onImageResize(imageDimensions) { onImageResize(imageDimensions) {
this.setOverlayDimensions(imageDimensions); this.setOverlayDimensions(imageDimensions);
this.setOverlayPosition(); this.setOverlayPosition();
this.$nextTick(() => {
if (this.initialLoad) {
// set focal point on initial load
this.shiftZoomFocalPoint();
this.initialLoad = false;
} else {
this.scaleZoomFocalPoint();
this.scrollToFocalPoint();
}
});
}, },
openCommentForm(position) { openCommentForm(position) {
const { x, y } = position; const { x, y } = position;
...@@ -89,9 +190,18 @@ export default { ...@@ -89,9 +190,18 @@ export default {
</script> </script>
<template> <template>
<div ref="presentationViewport" class="h-100 w-100 p-3 overflow-auto"> <div ref="presentationViewport" class="h-100 w-100 p-3 overflow-auto position-relative">
<div class="h-100 w-100 d-flex align-items-center position-relative"> <div
<design-image v-if="image" :image="image" :name="imageName" @resize="onImageResize" /> ref="presentationContainer"
class="h-100 w-100 d-flex align-items-center position-relative"
>
<design-image
v-if="image"
:image="image"
:name="imageName"
:scale="scale"
@resize="onImageResize"
/>
<design-overlay <design-overlay
v-if="overlayDimensions && overlayPosition" v-if="overlayDimensions && overlayPosition"
:dimensions="overlayDimensions" :dimensions="overlayDimensions"
......
<script>
import { GlIcon } from '@gitlab/ui';
const SCALE_STEP_SIZE = 0.2;
const DEFAULT_SCALE = 1;
const MIN_SCALE = 1;
const MAX_SCALE = 2;
export default {
components: {
GlIcon,
},
data() {
return {
scale: DEFAULT_SCALE,
};
},
computed: {
disableReset() {
return this.scale <= MIN_SCALE;
},
disableDecrease() {
return this.scale === DEFAULT_SCALE;
},
disableIncrease() {
return this.scale >= MAX_SCALE;
},
},
methods: {
setScale(scale) {
if (scale < MIN_SCALE) {
return;
}
this.scale = Math.round(scale * 100) / 100;
this.$emit('scale', this.scale);
},
incrementScale() {
this.setScale(this.scale + SCALE_STEP_SIZE);
},
decrementScale() {
this.setScale(this.scale - SCALE_STEP_SIZE);
},
resetScale() {
this.setScale(DEFAULT_SCALE);
},
},
};
</script>
<template>
<div class="design-scaler btn-group" role="group">
<button class="btn" :disabled="disableDecrease" @click="decrementScale">
<span class="d-flex-center gl-icon s16">
</span>
</button>
<button class="btn" :disabled="disableReset" @click="resetScale">
<gl-icon name="redo" />
</button>
<button class="btn" :disabled="disableIncrease" @click="incrementScale">
<gl-icon name="plus" />
</button>
</div>
</template>
...@@ -13,46 +13,85 @@ export default { ...@@ -13,46 +13,85 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
scale: {
type: Number,
required: false,
default: 1,
},
},
data() {
return {
baseImageSize: null,
imageStyle: null,
};
},
watch: {
scale(val) {
this.zoom(val);
},
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('resize', this.resizeThrottled, false); window.removeEventListener('resize', this.resizeThrottled, false);
}, },
mounted() { mounted() {
this.onImgLoad(); this.onImgLoad();
this.resizeThrottled = _.throttle(this.onImgLoad, 400);
this.resizeThrottled = _.throttle(() => {
this.onWindowResize();
}, 400);
window.addEventListener('resize', this.resizeThrottled, false); window.addEventListener('resize', this.resizeThrottled, false);
}, },
methods: { methods: {
onImgLoad() { onImgLoad() {
requestIdleCallback(this.calculateImgSize, { timeout: 1000 }); requestIdleCallback(this.setBaseImageSize, { timeout: 1000 });
}, },
calculateImgSize() { onWindowResize() {
const { contentImg } = this.$refs; const { contentImg } = this.$refs;
if (!contentImg) return; if (!contentImg) return;
this.$nextTick(() => { this.onResize({
const naturalRatio = contentImg.naturalWidth / contentImg.naturalHeight; width: contentImg.offsetWidth,
const visibleRatio = contentImg.width / contentImg.height; height: contentImg.offsetHeight,
});
},
setBaseImageSize() {
const { contentImg } = this.$refs;
if (!contentImg || contentImg.offsetHeight === 0 || contentImg.offsetWidth === 0) return;
const position = { this.baseImageSize = {
// Handling the case where img element takes more width than visible image thanks to object-fit: contain height: contentImg.offsetHeight,
width: width: contentImg.offsetWidth,
naturalRatio < visibleRatio };
? contentImg.clientHeight * naturalRatio this.onResize({ width: this.baseImageSize.width, height: this.baseImageSize.height });
: contentImg.clientWidth, },
height: contentImg.clientHeight, onResize({ width, height }) {
}; this.$emit('resize', { width, height });
},
zoom(amount) {
const width = this.baseImageSize.width * amount;
const height = this.baseImageSize.height * amount;
this.$emit('resize', position); this.imageStyle = {
}); width: `${width}px`,
height: `${height}px`,
};
this.onResize({ width, height });
}, },
}, },
}; };
</script> </script>
<template> <template>
<div class="m-auto h-100 w-100 d-flex-center js-design-image"> <div class="m-auto js-design-image" :class="{ 'h-100 w-100 d-flex-center': !imageStyle }">
<img ref="contentImg" :src="image" :alt="name" class="img-fluid mh-100" @load="onImgLoad" /> <img
ref="contentImg"
class="mh-100"
:src="image"
:alt="name"
:style="imageStyle"
:class="{ 'img-fluid': !imageStyle }"
@load="onImgLoad"
/>
</div> </div>
</template> </template>
...@@ -8,6 +8,7 @@ import Toolbar from '../../components/toolbar/index.vue'; ...@@ -8,6 +8,7 @@ import Toolbar from '../../components/toolbar/index.vue';
import DesignDiscussion from '../../components/design_notes/design_discussion.vue'; import DesignDiscussion from '../../components/design_notes/design_discussion.vue';
import DesignReplyForm from '../../components/design_notes/design_reply_form.vue'; import DesignReplyForm from '../../components/design_notes/design_reply_form.vue';
import DesignDestroyer from '../../components/design_destroyer.vue'; import DesignDestroyer from '../../components/design_destroyer.vue';
import DesignScaler from '../../components/design_scaler.vue';
import Participants from '~/sidebar/components/participants/participants.vue'; import Participants from '~/sidebar/components/participants/participants.vue';
import DesignPresentation from '../../components/design_presentation.vue'; import DesignPresentation from '../../components/design_presentation.vue';
import getDesignQuery from '../../graphql/queries/getDesign.query.graphql'; import getDesignQuery from '../../graphql/queries/getDesign.query.graphql';
...@@ -31,6 +32,7 @@ export default { ...@@ -31,6 +32,7 @@ export default {
ApolloMutation, ApolloMutation,
DesignPresentation, DesignPresentation,
DesignDiscussion, DesignDiscussion,
DesignScaler,
DesignDestroyer, DesignDestroyer,
Toolbar, Toolbar,
DesignReplyForm, DesignReplyForm,
...@@ -53,6 +55,7 @@ export default { ...@@ -53,6 +55,7 @@ export default {
projectPath: '', projectPath: '',
errorMessage: '', errorMessage: '',
issueIid: '', issueIid: '',
scale: 1,
}; };
}, },
apollo: { apollo: {
...@@ -202,7 +205,7 @@ export default { ...@@ -202,7 +205,7 @@ export default {
> >
<gl-loading-icon v-if="isLoading" size="xl" class="align-self-center" /> <gl-loading-icon v-if="isLoading" size="xl" class="align-self-center" />
<template v-else> <template v-else>
<div class="d-flex overflow-hidden flex-lg-grow-1 flex-column"> <div class="d-flex overflow-hidden flex-grow-1 flex-column position-relative">
<design-destroyer <design-destroyer
:filenames="[design.filename]" :filenames="[design.filename]"
:project-path="projectPath" :project-path="projectPath"
...@@ -226,14 +229,17 @@ export default { ...@@ -226,14 +229,17 @@ export default {
{{ errorMessage }} {{ errorMessage }}
</gl-alert> </gl-alert>
</div> </div>
<design-presentation <design-presentation
:image="design.image" :image="design.image"
:image-name="design.filename" :image-name="design.filename"
:discussions="discussions" :discussions="discussions"
:is-annotating="isAnnotating" :is-annotating="isAnnotating"
:scale="scale"
@openCommentForm="openCommentForm" @openCommentForm="openCommentForm"
/> />
<div class="design-scaler-wrapper position-absolute w-100 pb-4 d-flex-center">
<design-scaler @scale="scale = $event" />
</div>
</div> </div>
<div class="image-notes"> <div class="image-notes">
<h2 class="gl-font-size-20 font-weight-bold mt-0">{{ issue.title }}</h2> <h2 class="gl-font-size-20 font-weight-bold mt-0">{{ issue.title }}</h2>
......
.design-image {
object-fit: contain;
}
.design-presentation-wrapper { .design-presentation-wrapper {
top: 0; top: 0;
left: 0; left: 0;
} }
.design-scaler {
z-index: 1;
}
.design-scaler-wrapper {
bottom: 0;
left: 0;
}
.design-list-item .design-event { .design-list-item .design-event {
top: $gl-padding; top: $gl-padding;
right: $gl-padding; right: $gl-padding;
......
---
title: Add zooming functionality to designs in Design view
merge_request: 22863
author:
type: added
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management design presentation component currentCommentForm currentCommentForm is equal to current annotation coordinates when isAnnotating is true 1`] = `
<div
class="h-100 w-100 p-3 overflow-auto position-relative"
>
<div
class="h-100 w-100 d-flex align-items-center position-relative"
>
<design-image-stub
image="test.jpg"
name="test"
scale="1"
/>
<design-overlay-stub
currentcommentform="[object Object]"
dimensions="[object Object]"
notes=""
position="[object Object]"
/>
</div>
</div>
`;
exports[`Design management design presentation component currentCommentForm currentCommentForm is null when isAnnotating is false 1`] = `
<div
class="h-100 w-100 p-3 overflow-auto position-relative"
>
<div
class="h-100 w-100 d-flex align-items-center position-relative"
>
<design-image-stub
image="test.jpg"
name="test"
scale="1"
/>
<design-overlay-stub
dimensions="[object Object]"
notes=""
position="[object Object]"
/>
</div>
</div>
`;
exports[`Design management design presentation component currentCommentForm currentCommentForm is null when isAnnotating is true but annotation coordinates are falsey 1`] = `
<div
class="h-100 w-100 p-3 overflow-auto position-relative"
>
<div
class="h-100 w-100 d-flex align-items-center position-relative"
>
<design-image-stub
image="test.jpg"
name="test"
scale="1"
/>
<design-overlay-stub
dimensions="[object Object]"
notes=""
position="[object Object]"
/>
</div>
</div>
`;
exports[`Design management design presentation component renders empty state when no image provided 1`] = ` exports[`Design management design presentation component renders empty state when no image provided 1`] = `
<div <div
class="h-100 w-100 p-3 overflow-auto" class="h-100 w-100 p-3 overflow-auto position-relative"
> >
<div <div
class="h-100 w-100 d-flex align-items-center position-relative" class="h-100 w-100 d-flex align-items-center position-relative"
...@@ -16,7 +83,7 @@ exports[`Design management design presentation component renders empty state whe ...@@ -16,7 +83,7 @@ exports[`Design management design presentation component renders empty state whe
exports[`Design management design presentation component renders image and overlay when image provided 1`] = ` exports[`Design management design presentation component renders image and overlay when image provided 1`] = `
<div <div
class="h-100 w-100 p-3 overflow-auto" class="h-100 w-100 p-3 overflow-auto position-relative"
> >
<div <div
class="h-100 w-100 d-flex align-items-center position-relative" class="h-100 w-100 d-flex align-items-center position-relative"
...@@ -24,6 +91,7 @@ exports[`Design management design presentation component renders image and overl ...@@ -24,6 +91,7 @@ exports[`Design management design presentation component renders image and overl
<design-image-stub <design-image-stub
image="test.jpg" image="test.jpg"
name="test" name="test"
scale="1"
/> />
<design-overlay-stub <design-overlay-stub
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Design management design scaler component minus and reset buttons are disabled when scale === 1 1`] = `
<div
class="design-scaler btn-group"
role="group"
>
<button
class="btn"
disabled="disabled"
>
<span
class="d-flex-center gl-icon s16"
>
</span>
</button>
<button
class="btn"
disabled="disabled"
>
<gl-icon-stub
name="redo"
size="16"
/>
</button>
<button
class="btn"
>
<gl-icon-stub
name="plus"
size="16"
/>
</button>
</div>
`;
exports[`Design management design scaler component minus and reset buttons are enabled when scale > 1 1`] = `
<div
class="design-scaler btn-group"
role="group"
>
<button
class="btn"
>
<span
class="d-flex-center gl-icon s16"
>
</span>
</button>
<button
class="btn"
>
<gl-icon-stub
name="redo"
size="16"
/>
</button>
<button
class="btn"
>
<gl-icon-stub
name="plus"
size="16"
/>
</button>
</div>
`;
exports[`Design management design scaler component plus button is disabled when scale === 2 1`] = `
<div
class="design-scaler btn-group"
role="group"
>
<button
class="btn"
>
<span
class="d-flex-center gl-icon s16"
>
</span>
</button>
<button
class="btn"
>
<gl-icon-stub
name="redo"
size="16"
/>
</button>
<button
class="btn"
disabled="disabled"
>
<gl-icon-stub
name="plus"
size="16"
/>
</button>
</div>
`;
...@@ -2,11 +2,11 @@ ...@@ -2,11 +2,11 @@
exports[`Design management large image component renders image 1`] = ` exports[`Design management large image component renders image 1`] = `
<div <div
class="m-auto h-100 w-100 d-flex-center js-design-image" class="m-auto js-design-image h-100 w-100 d-flex-center"
> >
<img <img
alt="test" alt="test"
class="img-fluid mh-100" class="mh-100 img-fluid"
src="test.jpg" src="test.jpg"
/> />
</div> </div>
...@@ -14,13 +14,39 @@ exports[`Design management large image component renders image 1`] = ` ...@@ -14,13 +14,39 @@ exports[`Design management large image component renders image 1`] = `
exports[`Design management large image component renders loading state 1`] = ` exports[`Design management large image component renders loading state 1`] = `
<div <div
class="m-auto h-100 w-100 d-flex-center js-design-image" class="m-auto js-design-image h-100 w-100 d-flex-center"
isloading="true" isloading="true"
> >
<img <img
alt="" alt=""
class="img-fluid mh-100" class="mh-100 img-fluid"
src="" src=""
/> />
</div> </div>
`; `;
exports[`Design management large image component sets correct classes and styles if imageStyle is set 1`] = `
<div
class="m-auto js-design-image"
>
<img
alt="test"
class="mh-100"
src="test.jpg"
style="width: 100px; height: 100px;"
/>
</div>
`;
exports[`Design management large image component zoom sets image style when zoomed 1`] = `
<div
class="m-auto js-design-image"
>
<img
alt="test"
class="mh-100"
src="test.jpg"
style="width: 200px; height: 200px;"
/>
</div>
`;
...@@ -75,47 +75,64 @@ describe('Design management design presentation component', () => { ...@@ -75,47 +75,64 @@ describe('Design management design presentation component', () => {
}); });
}); });
it('currentCommentForm is null when isAnnotating is false', () => { describe('currentCommentForm', () => {
createComponent({ it('currentCommentForm is null when isAnnotating is false', () => {
image: 'test.jpg', createComponent(
imageName: 'test', {
image: 'test.jpg',
imageName: 'test',
},
mockOverlayData,
);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.currentCommentForm).toBeNull();
expect(wrapper.element).toMatchSnapshot();
});
}); });
expect(wrapper.vm.currentCommentForm).toBeNull(); it('currentCommentForm is null when isAnnotating is true but annotation coordinates are falsey', () => {
}); createComponent(
{
image: 'test.jpg',
imageName: 'test',
isAnnotating: true,
},
mockOverlayData,
);
it('currentCommentForm is null when isAnnotating is true but annotation coordinates are falsey', () => { return wrapper.vm.$nextTick().then(() => {
createComponent({ expect(wrapper.vm.currentCommentForm).toBeNull();
image: 'test.jpg', expect(wrapper.element).toMatchSnapshot();
imageName: 'test', });
isAnnotating: true,
}); });
expect(wrapper.vm.currentCommentForm).toBeNull(); it('currentCommentForm is equal to current annotation coordinates when isAnnotating is true', () => {
}); createComponent(
{
it('currentCommentForm is equal to current annotation coordinates when isAnnotating is true', () => { image: 'test.jpg',
createComponent( imageName: 'test',
{ isAnnotating: true,
image: 'test.jpg', },
imageName: 'test', {
isAnnotating: true, ...mockOverlayData,
}, currentAnnotationCoordinates: {
{ x: 1,
currentAnnotationCoordinates: { y: 1,
width: 100,
height: 100,
},
},
);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.currentCommentForm).toEqual({
x: 1, x: 1,
y: 1, y: 1,
width: 100, width: 100,
height: 100, height: 100,
}, });
}, expect(wrapper.element).toMatchSnapshot();
); });
expect(wrapper.vm.currentCommentForm).toEqual({
x: 1,
y: 1,
width: 100,
height: 100,
}); });
}); });
...@@ -135,8 +152,10 @@ describe('Design management design presentation component', () => { ...@@ -135,8 +152,10 @@ describe('Design management design presentation component', () => {
}); });
it('sets overlay position correctly when overlay is smaller than viewport', () => { it('sets overlay position correctly when overlay is smaller than viewport', () => {
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200); jest.spyOn(wrapper.vm.$refs.presentationContainer, 'offsetWidth', 'get').mockReturnValue(200);
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200); jest
.spyOn(wrapper.vm.$refs.presentationContainer, 'offsetHeight', 'get')
.mockReturnValue(200);
wrapper.vm.setOverlayPosition(); wrapper.vm.setOverlayPosition();
expect(wrapper.vm.overlayPosition).toEqual({ expect(wrapper.vm.overlayPosition).toEqual({
...@@ -146,8 +165,10 @@ describe('Design management design presentation component', () => { ...@@ -146,8 +165,10 @@ describe('Design management design presentation component', () => {
}); });
it('sets overlay position correctly when overlay width is larger than viewports', () => { it('sets overlay position correctly when overlay width is larger than viewports', () => {
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(50); jest.spyOn(wrapper.vm.$refs.presentationContainer, 'offsetWidth', 'get').mockReturnValue(50);
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200); jest
.spyOn(wrapper.vm.$refs.presentationContainer, 'offsetHeight', 'get')
.mockReturnValue(200);
wrapper.vm.setOverlayPosition(); wrapper.vm.setOverlayPosition();
expect(wrapper.vm.overlayPosition).toEqual({ expect(wrapper.vm.overlayPosition).toEqual({
...@@ -157,8 +178,8 @@ describe('Design management design presentation component', () => { ...@@ -157,8 +178,8 @@ describe('Design management design presentation component', () => {
}); });
it('sets overlay position correctly when overlay height is larger than viewports', () => { it('sets overlay position correctly when overlay height is larger than viewports', () => {
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200); jest.spyOn(wrapper.vm.$refs.presentationContainer, 'offsetWidth', 'get').mockReturnValue(200);
jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(50); jest.spyOn(wrapper.vm.$refs.presentationContainer, 'offsetHeight', 'get').mockReturnValue(50);
wrapper.vm.setOverlayPosition(); wrapper.vm.setOverlayPosition();
expect(wrapper.vm.overlayPosition).toEqual({ expect(wrapper.vm.overlayPosition).toEqual({
...@@ -167,4 +188,162 @@ describe('Design management design presentation component', () => { ...@@ -167,4 +188,162 @@ describe('Design management design presentation component', () => {
}); });
}); });
}); });
describe('getViewportCenter', () => {
/**
* Spy on $refs.presentationViewport with given values
* @param {Object} viewportDimensions {width, height}
* @param {Object} childDimensions {width, height}
* @param {Float} scrollTopPerc 0 < x < 1
* @param {Float} scrollLeftPerc 0 < x < 1
*/
const spyOnPresentationViewport = (
viewportDimensions,
childDimensions,
scrollTopPerc,
scrollLeftPerc,
) => {
jest
.spyOn(wrapper.vm.$refs.presentationViewport, 'scrollWidth', 'get')
.mockReturnValue(childDimensions.width);
jest
.spyOn(wrapper.vm.$refs.presentationViewport, 'scrollHeight', 'get')
.mockReturnValue(childDimensions.height);
jest
.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get')
.mockReturnValue(viewportDimensions.width);
jest
.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get')
.mockReturnValue(viewportDimensions.height);
jest
.spyOn(wrapper.vm.$refs.presentationViewport, 'scrollLeft', 'get')
.mockReturnValue((childDimensions.width - viewportDimensions.width) * scrollLeftPerc);
jest
.spyOn(wrapper.vm.$refs.presentationViewport, 'scrollTop', 'get')
.mockReturnValue((childDimensions.height - viewportDimensions.height) * scrollTopPerc);
};
beforeEach(() => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
mockOverlayData,
);
});
it('calculate center correctly with no scroll', () => {
spyOnPresentationViewport({ width: 10, height: 10 }, { width: 20, height: 20 }, 0, 0);
expect(wrapper.vm.getViewportCenter()).toEqual({
x: 5,
y: 5,
});
});
it('calculate center correctly with some scroll', () => {
spyOnPresentationViewport({ width: 10, height: 10 }, { width: 20, height: 20 }, 0.5, 0.5);
expect(wrapper.vm.getViewportCenter()).toEqual({
x: 10,
y: 10,
});
});
it('Returns default case if no overflow (scrollWidth==offsetWidth, etc.)', () => {
spyOnPresentationViewport({ width: 20, height: 20 }, { width: 20, height: 20 }, 0.5, 0.5);
expect(wrapper.vm.getViewportCenter()).toEqual({
x: 10,
y: 10,
});
});
});
describe('scaleZoomFocalPoint', () => {
it('scaleZoomFocalPoint scales focal point correctly when zooming in', () => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
{
...mockOverlayData,
zoomFocalPoint: {
x: 5,
y: 5,
width: 50,
height: 50,
},
},
);
wrapper.vm.scaleZoomFocalPoint();
expect(wrapper.vm.zoomFocalPoint).toEqual({
x: 10,
y: 10,
width: 100,
height: 100,
});
});
it('scaleZoomFocalPoint scales focal point correctly when zooming out', () => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
{
...mockOverlayData,
zoomFocalPoint: {
x: 10,
y: 10,
width: 200,
height: 200,
},
},
);
wrapper.vm.scaleZoomFocalPoint();
expect(wrapper.vm.zoomFocalPoint).toEqual({
x: 5,
y: 5,
width: 100,
height: 100,
});
});
});
describe('onImageResize', () => {
it('sets zoom focal point on initial load', () => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
mockOverlayData,
);
wrapper.setMethods({
shiftZoomFocalPoint: jest.fn(),
scaleZoomFocalPoint: jest.fn(),
scrollToFocalPoint: jest.fn(),
});
wrapper.vm.onImageResize({ width: 10, height: 10 });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.shiftZoomFocalPoint).toHaveBeenCalled();
expect(wrapper.vm.initialLoad).toBe(false);
});
});
it('calls scaleZoomFocalPoint and scrollToFocalPoint after initial load', () => {
wrapper.vm.onImageResize({ width: 10, height: 10 });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.scaleZoomFocalPoint).toHaveBeenCalled();
expect(wrapper.vm.scrollToFocalPoint).toHaveBeenCalled();
});
});
});
}); });
import { shallowMount } from '@vue/test-utils';
import DesignScaler from 'ee/design_management/components/design_scaler.vue';
describe('Design management design scaler component', () => {
let wrapper;
function createComponent(propsData, data = {}) {
wrapper = shallowMount(DesignScaler, {
propsData,
});
wrapper.setData(data);
}
afterEach(() => {
wrapper.destroy();
});
const getButton = type => {
const buttonTypeOrder = ['minus', 'reset', 'plus'];
const buttons = wrapper.findAll('button');
return buttons.at(buttonTypeOrder.indexOf(type));
};
it('emits @scale event when "plus" button clicked', () => {
createComponent();
getButton('plus').trigger('click');
expect(wrapper.emitted('scale')).toEqual([[1.2]]);
});
it('emits @scale event when "reset" button clicked (scale > 1)', () => {
createComponent({}, { scale: 1.6 });
return wrapper.vm.$nextTick().then(() => {
getButton('reset').trigger('click');
expect(wrapper.emitted('scale')).toEqual([[1]]);
});
});
it('emits @scale event when "minus" button clicked (scale > 1)', () => {
createComponent({}, { scale: 1.6 });
return wrapper.vm.$nextTick().then(() => {
getButton('minus').trigger('click');
expect(wrapper.emitted('scale')).toEqual([[1.4]]);
});
});
it('minus and reset buttons are disabled when scale === 1', () => {
createComponent();
expect(wrapper.element).toMatchSnapshot();
});
it('minus and reset buttons are enabled when scale > 1', () => {
createComponent({}, { scale: 1.2 });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
it('plus button is disabled when scale === 2', () => {
createComponent({}, { scale: 2 });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
});
...@@ -4,10 +4,11 @@ import DesignImage from 'ee/design_management/components/image.vue'; ...@@ -4,10 +4,11 @@ import DesignImage from 'ee/design_management/components/image.vue';
describe('Design management large image component', () => { describe('Design management large image component', () => {
let wrapper; let wrapper;
function createComponent(propsData) { function createComponent(propsData, data = {}) {
wrapper = shallowMount(DesignImage, { wrapper = shallowMount(DesignImage, {
propsData, propsData,
}); });
wrapper.setData(data);
} }
afterEach(() => { afterEach(() => {
...@@ -31,4 +32,69 @@ describe('Design management large image component', () => { ...@@ -31,4 +32,69 @@ describe('Design management large image component', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
it('sets correct classes and styles if imageStyle is set', () => {
createComponent(
{
isLoading: false,
image: 'test.jpg',
name: 'test',
},
{
imageStyle: {
width: '100px',
height: '100px',
},
},
);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
describe('zoom', () => {
beforeEach(() => {
createComponent(
{
isLoading: false,
image: 'test.jpg',
name: 'test',
},
{
imageStyle: {
width: '100px',
height: '100px',
},
baseImageSize: {
width: 100,
height: 100,
},
},
);
});
it('emits @resize event on zoom', () => {
wrapper.vm.zoom(2);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('resize')).toEqual([[{ width: 200, height: 200 }]]);
});
});
it('emits @resize event with base image size when scale=1', () => {
wrapper.vm.zoom(1);
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.emitted('resize')).toEqual([[{ width: 100, height: 100 }]]);
});
});
it('sets image style when zoomed', () => {
wrapper.vm.zoom(2);
expect(wrapper.vm.imageStyle).toEqual({ width: '200px', height: '200px' });
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.element).toMatchSnapshot();
});
});
});
}); });
...@@ -5,7 +5,7 @@ exports[`Design management design index page renders design index 1`] = ` ...@@ -5,7 +5,7 @@ exports[`Design management design index page renders design index 1`] = `
class="design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" class="design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
> >
<div <div
class="d-flex overflow-hidden flex-lg-grow-1 flex-column" class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"
> >
<design-destroyer-stub <design-destroyer-stub
filenames="test.jpg" filenames="test.jpg"
...@@ -19,7 +19,14 @@ exports[`Design management design index page renders design index 1`] = ` ...@@ -19,7 +19,14 @@ exports[`Design management design index page renders design index 1`] = `
discussions="[object Object]" discussions="[object Object]"
image="test.jpg" image="test.jpg"
imagename="test.jpg" imagename="test.jpg"
scale="1"
/> />
<div
class="design-scaler-wrapper position-absolute w-100 pb-4 d-flex-center"
>
<design-scaler-stub />
</div>
</div> </div>
<div <div
...@@ -75,7 +82,7 @@ exports[`Design management design index page with error GlAlert is rendered in c ...@@ -75,7 +82,7 @@ exports[`Design management design index page with error GlAlert is rendered in c
class="design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" class="design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row"
> >
<div <div
class="d-flex overflow-hidden flex-lg-grow-1 flex-column" class="d-flex overflow-hidden flex-grow-1 flex-column position-relative"
> >
<design-destroyer-stub <design-destroyer-stub
filenames="test.jpg" filenames="test.jpg"
...@@ -106,7 +113,14 @@ exports[`Design management design index page with error GlAlert is rendered in c ...@@ -106,7 +113,14 @@ exports[`Design management design index page with error GlAlert is rendered in c
discussions="" discussions=""
image="test.jpg" image="test.jpg"
imagename="test.jpg" imagename="test.jpg"
scale="1"
/> />
<div
class="design-scaler-wrapper position-absolute w-100 pb-4 d-flex-center"
>
<design-scaler-stub />
</div>
</div> </div>
<div <div
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment