Commit 55dee38f authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'tq/click-drag-design-explore' into 'master'

Design view: Implement design exploration via click-and-drag

See merge request gitlab-org/gitlab!25405
parents c62bd48e b5cbec88
...@@ -126,6 +126,8 @@ to help summarize changes between versions. ...@@ -126,6 +126,8 @@ to help summarize changes between versions.
Designs can be explored in greater detail by zooming in and out of the image. Designs can be explored in greater detail by zooming in and out of the image.
Control the amount of zoom with the `+` and `-` buttons at the bottom of the image. Control the amount of zoom with the `+` and `-` buttons at the bottom of the image.
While zoomed, you can still [start new discussions](#starting-discussions-on-designs) on the image, and see any existing ones. While zoomed, you can still [start new discussions](#starting-discussions-on-designs) on the image, and see any existing ones.
[Introduced](https://gitlab.com/gitlab-org/gitlab/issues/197324) in GitLab 12.10, while zoomed in,
you can click-and-drag on the image to move around it.
![Design zooming](img/design_zooming_v12_7.png) ![Design zooming](img/design_zooming_v12_7.png)
......
...@@ -25,6 +25,11 @@ export default { ...@@ -25,6 +25,11 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
disableCommenting: {
type: Boolean,
required: false,
default: false,
},
}, },
data() { data() {
return { return {
...@@ -34,7 +39,10 @@ export default { ...@@ -34,7 +39,10 @@ export default {
}, },
computed: { computed: {
overlayStyle() { overlayStyle() {
const cursor = this.disableCommenting ? 'unset' : undefined;
return { return {
cursor,
width: `${this.dimensions.width}px`, width: `${this.dimensions.width}px`,
height: `${this.dimensions.height}px`, height: `${this.dimensions.height}px`,
...this.position, ...this.position,
...@@ -207,6 +215,7 @@ export default { ...@@ -207,6 +215,7 @@ export default {
@mouseleave="onNoteMouseup" @mouseleave="onNoteMouseup"
> >
<button <button
v-show="!disableCommenting"
type="button" type="button"
class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button" class="btn-transparent position-absolute image-diff-overlay-add-comment w-100 h-100 js-add-image-diff-note-button"
data-qa-selector="design_image_button" data-qa-selector="design_image_button"
...@@ -222,15 +231,15 @@ export default { ...@@ -222,15 +231,15 @@ export default {
? getNotePositionStyle(movingNoteNewPosition) ? getNotePositionStyle(movingNoteNewPosition)
: getNotePositionStyle(note.position) : getNotePositionStyle(note.position)
" "
@mousedown="onNoteMousedown($event, note)" @mousedown.stop="onNoteMousedown($event, note)"
@mouseup="onNoteMouseup" @mouseup.stop="onNoteMouseup"
/> />
<design-note-pin <design-note-pin
v-if="currentCommentForm" v-if="currentCommentForm"
:position="currentCommentPositionStyle" :position="currentCommentPositionStyle"
:repositioning="isMovingCurrentComment" :repositioning="isMovingCurrentComment"
@mousedown="onNoteMousedown" @mousedown.stop="onNoteMousedown"
@mouseup="onNoteMouseup" @mouseup.stop="onNoteMouseup"
/> />
</div> </div>
</template> </template>
...@@ -46,6 +46,7 @@ export default { ...@@ -46,6 +46,7 @@ export default {
height: 0, height: 0,
}, },
initialLoad: true, initialLoad: true,
lastDragPosition: null,
}; };
}, },
computed: { computed: {
...@@ -55,6 +56,14 @@ export default { ...@@ -55,6 +56,14 @@ export default {
currentCommentForm() { currentCommentForm() {
return (this.isAnnotating && this.currentAnnotationPosition) || null; return (this.isAnnotating && this.currentAnnotationPosition) || null;
}, },
presentationStyle() {
return {
cursor: this.isDraggingDesign ? 'grabbing' : undefined,
};
},
isDraggingDesign() {
return Boolean(this.lastDragPosition);
},
}, },
beforeDestroy() { beforeDestroy() {
const { presentationViewport } = this.$refs; const { presentationViewport } = this.$refs;
...@@ -206,12 +215,60 @@ export default { ...@@ -206,12 +215,60 @@ export default {
const position = this.getAnnotationPositon(coordinates); const position = this.getAnnotationPositon(coordinates);
this.$emit('moveNote', { noteId, discussionId, position }); this.$emit('moveNote', { noteId, discussionId, position });
}, },
onPresentationMousedown({ clientX, clientY }) {
if (!this.isDesignOverflowing()) return;
this.lastDragPosition = {
x: clientX,
y: clientY,
};
},
onPresentationMousemove({ clientX, clientY }) {
if (!this.lastDragPosition) return;
const { presentationViewport } = this.$refs;
if (!presentationViewport) return;
const { scrollLeft, scrollTop } = presentationViewport;
const deltaX = this.lastDragPosition.x - clientX;
const deltaY = this.lastDragPosition.y - clientY;
presentationViewport.scrollTo(scrollLeft + deltaX, scrollTop + deltaY);
this.lastDragPosition = {
x: clientX,
y: clientY,
};
},
onPresentationMouseup() {
this.lastDragPosition = null;
},
isDesignOverflowing() {
const { presentationContainer } = this.$refs;
if (!presentationContainer) return false;
return (
presentationContainer.scrollWidth > presentationContainer.offsetWidth ||
presentationContainer.scrollHeight > presentationContainer.offsetHeight
);
},
}, },
}; };
</script> </script>
<template> <template>
<div ref="presentationViewport" class="h-100 w-100 p-3 overflow-auto position-relative"> <div
ref="presentationViewport"
class="h-100 w-100 p-3 overflow-auto position-relative"
:style="presentationStyle"
@mousedown="onPresentationMousedown"
@mousemove="onPresentationMousemove"
@mouseup="onPresentationMouseup"
@mouseleave="onPresentationMouseup"
@touchstart="onPresentationMousedown"
@touchmove="onPresentationMousemove"
@touchend="onPresentationMouseup"
@touchcancel="onPresentationMouseup"
>
<div <div
ref="presentationContainer" ref="presentationContainer"
class="h-100 w-100 d-flex align-items-center position-relative" class="h-100 w-100 d-flex align-items-center position-relative"
...@@ -229,6 +286,7 @@ export default { ...@@ -229,6 +286,7 @@ export default {
:position="overlayPosition" :position="overlayPosition"
:notes="discussionStartingNotes" :notes="discussionStartingNotes"
:current-comment-form="currentCommentForm" :current-comment-form="currentCommentForm"
:disable-commenting="isDraggingDesign"
@openCommentForm="openCommentForm" @openCommentForm="openCommentForm"
@moveNote="moveNote" @moveNote="moveNote"
/> />
......
---
title: Add ability to explore zoomed in designs via click-and-drag
merge_request: 25405
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 position when isAnnotating is true 1`] = ` exports[`Design management design presentation component currentCommentForm is equal to current annotation position when isAnnotating is true 1`] = `
<div <div
class="h-100 w-100 p-3 overflow-auto position-relative" class="h-100 w-100 p-3 overflow-auto position-relative"
> >
...@@ -23,7 +23,7 @@ exports[`Design management design presentation component currentCommentForm curr ...@@ -23,7 +23,7 @@ exports[`Design management design presentation component currentCommentForm curr
</div> </div>
`; `;
exports[`Design management design presentation component currentCommentForm currentCommentForm is null when isAnnotating is false 1`] = ` exports[`Design management design presentation component currentCommentForm is null when isAnnotating is false 1`] = `
<div <div
class="h-100 w-100 p-3 overflow-auto position-relative" class="h-100 w-100 p-3 overflow-auto position-relative"
> >
...@@ -45,7 +45,7 @@ exports[`Design management design presentation component currentCommentForm curr ...@@ -45,7 +45,7 @@ exports[`Design management design presentation component currentCommentForm curr
</div> </div>
`; `;
exports[`Design management design presentation component currentCommentForm currentCommentForm is null when isAnnotating is true but annotation position is falsey 1`] = ` exports[`Design management design presentation component currentCommentForm is null when isAnnotating is true but annotation position is falsey 1`] = `
<div <div
class="h-100 w-100 p-3 overflow-auto position-relative" class="h-100 w-100 p-3 overflow-auto position-relative"
> >
......
...@@ -31,6 +31,58 @@ describe('Design management design presentation component', () => { ...@@ -31,6 +31,58 @@ describe('Design management design presentation component', () => {
wrapper.setData(data); wrapper.setData(data);
} }
/**
* Spy on $refs and mock given values
* @param {Object} viewportDimensions {width, height}
* @param {Object} childDimensions {width, height}
* @param {Float} scrollTopPerc 0 < x < 1
* @param {Float} scrollLeftPerc 0 < x < 1
*/
function mockRefDimensions(
ref,
viewportDimensions,
childDimensions,
scrollTopPerc,
scrollLeftPerc,
) {
jest.spyOn(ref, 'scrollWidth', 'get').mockReturnValue(childDimensions.width);
jest.spyOn(ref, 'scrollHeight', 'get').mockReturnValue(childDimensions.height);
jest.spyOn(ref, 'offsetWidth', 'get').mockReturnValue(viewportDimensions.width);
jest.spyOn(ref, 'offsetHeight', 'get').mockReturnValue(viewportDimensions.height);
jest
.spyOn(ref, 'scrollLeft', 'get')
.mockReturnValue((childDimensions.width - viewportDimensions.width) * scrollLeftPerc);
jest
.spyOn(ref, 'scrollTop', 'get')
.mockReturnValue((childDimensions.height - viewportDimensions.height) * scrollTopPerc);
}
function clickDragExplore(startCoords, endCoords, { useTouchEvents } = {}) {
const event = useTouchEvents
? {
mousedown: 'touchstart',
mousemove: 'touchmove',
mouseup: 'touchend',
}
: {
mousedown: 'mousedown',
mousemove: 'mousemove',
mouseup: 'mouseup',
};
wrapper.trigger(event.mousedown, {
clientX: startCoords.clientX,
clientY: startCoords.clientY,
});
return wrapper.vm.$nextTick().then(() => {
wrapper.trigger(event.mousemove, {
clientX: endCoords.clientX,
clientY: endCoords.clientY,
});
return wrapper.vm.$nextTick();
});
}
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
...@@ -76,7 +128,7 @@ describe('Design management design presentation component', () => { ...@@ -76,7 +128,7 @@ describe('Design management design presentation component', () => {
}); });
describe('currentCommentForm', () => { describe('currentCommentForm', () => {
it('currentCommentForm is null when isAnnotating is false', () => { it('is null when isAnnotating is false', () => {
createComponent( createComponent(
{ {
image: 'test.jpg', image: 'test.jpg',
...@@ -91,7 +143,7 @@ describe('Design management design presentation component', () => { ...@@ -91,7 +143,7 @@ describe('Design management design presentation component', () => {
}); });
}); });
it('currentCommentForm is null when isAnnotating is true but annotation position is falsey', () => { it('is null when isAnnotating is true but annotation position is falsey', () => {
createComponent( createComponent(
{ {
image: 'test.jpg', image: 'test.jpg',
...@@ -107,7 +159,7 @@ describe('Design management design presentation component', () => { ...@@ -107,7 +159,7 @@ describe('Design management design presentation component', () => {
}); });
}); });
it('currentCommentForm is equal to current annotation position when isAnnotating is true', () => { it('is equal to current annotation position when isAnnotating is true', () => {
createComponent( createComponent(
{ {
image: 'test.jpg', image: 'test.jpg',
...@@ -124,6 +176,7 @@ describe('Design management design presentation component', () => { ...@@ -124,6 +176,7 @@ describe('Design management design presentation component', () => {
}, },
}, },
); );
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.currentCommentForm).toEqual({ expect(wrapper.vm.currentCommentForm).toEqual({
x: 1, x: 1,
...@@ -190,39 +243,6 @@ describe('Design management design presentation component', () => { ...@@ -190,39 +243,6 @@ describe('Design management design presentation component', () => {
}); });
describe('getViewportCenter', () => { 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(() => { beforeEach(() => {
createComponent( createComponent(
{ {
...@@ -234,7 +254,13 @@ describe('Design management design presentation component', () => { ...@@ -234,7 +254,13 @@ describe('Design management design presentation component', () => {
}); });
it('calculate center correctly with no scroll', () => { it('calculate center correctly with no scroll', () => {
spyOnPresentationViewport({ width: 10, height: 10 }, { width: 20, height: 20 }, 0, 0); mockRefDimensions(
wrapper.vm.$refs.presentationViewport,
{ width: 10, height: 10 },
{ width: 20, height: 20 },
0,
0,
);
expect(wrapper.vm.getViewportCenter()).toEqual({ expect(wrapper.vm.getViewportCenter()).toEqual({
x: 5, x: 5,
...@@ -243,7 +269,13 @@ describe('Design management design presentation component', () => { ...@@ -243,7 +269,13 @@ describe('Design management design presentation component', () => {
}); });
it('calculate center correctly with some scroll', () => { it('calculate center correctly with some scroll', () => {
spyOnPresentationViewport({ width: 10, height: 10 }, { width: 20, height: 20 }, 0.5, 0.5); mockRefDimensions(
wrapper.vm.$refs.presentationViewport,
{ width: 10, height: 10 },
{ width: 20, height: 20 },
0.5,
0.5,
);
expect(wrapper.vm.getViewportCenter()).toEqual({ expect(wrapper.vm.getViewportCenter()).toEqual({
x: 10, x: 10,
...@@ -252,7 +284,13 @@ describe('Design management design presentation component', () => { ...@@ -252,7 +284,13 @@ describe('Design management design presentation component', () => {
}); });
it('Returns default case if no overflow (scrollWidth==offsetWidth, etc.)', () => { it('Returns default case if no overflow (scrollWidth==offsetWidth, etc.)', () => {
spyOnPresentationViewport({ width: 20, height: 20 }, { width: 20, height: 20 }, 0.5, 0.5); mockRefDimensions(
wrapper.vm.$refs.presentationViewport,
{ width: 20, height: 20 },
{ width: 20, height: 20 },
0.5,
0.5,
);
expect(wrapper.vm.getViewportCenter()).toEqual({ expect(wrapper.vm.getViewportCenter()).toEqual({
x: 10, x: 10,
...@@ -262,7 +300,7 @@ describe('Design management design presentation component', () => { ...@@ -262,7 +300,7 @@ describe('Design management design presentation component', () => {
}); });
describe('scaleZoomFocalPoint', () => { describe('scaleZoomFocalPoint', () => {
it('scaleZoomFocalPoint scales focal point correctly when zooming in', () => { it('scales focal point correctly when zooming in', () => {
createComponent( createComponent(
{ {
image: 'test.jpg', image: 'test.jpg',
...@@ -288,7 +326,7 @@ describe('Design management design presentation component', () => { ...@@ -288,7 +326,7 @@ describe('Design management design presentation component', () => {
}); });
}); });
it('scaleZoomFocalPoint scales focal point correctly when zooming out', () => { it('scales focal point correctly when zooming out', () => {
createComponent( createComponent(
{ {
image: 'test.jpg', image: 'test.jpg',
...@@ -346,4 +384,77 @@ describe('Design management design presentation component', () => { ...@@ -346,4 +384,77 @@ describe('Design management design presentation component', () => {
}); });
}); });
}); });
describe('onPresentationMousedown', () => {
it.each`
scenario | width | height
${'width overflows'} | ${101} | ${100}
${'height overflows'} | ${100} | ${101}
${'width and height overflows'} | ${200} | ${200}
`('sets lastDragPosition when design $scenario', ({ width, height }) => {
createComponent();
mockRefDimensions(
wrapper.vm.$refs.presentationContainer,
{ width: 100, height: 100 },
{ width, height },
);
const newLastDragPosition = { x: 2, y: 2 };
wrapper.vm.onPresentationMousedown({
clientX: newLastDragPosition.x,
clientY: newLastDragPosition.y,
});
expect(wrapper.vm.lastDragPosition).toStrictEqual(newLastDragPosition);
});
it('does not set lastDragPosition if design does not overflow', () => {
const lastDragPosition = { x: 1, y: 1 };
createComponent({}, { lastDragPosition });
mockRefDimensions(
wrapper.vm.$refs.presentationContainer,
{ width: 100, height: 100 },
{ width: 50, height: 50 },
);
wrapper.vm.onPresentationMousedown({ clientX: 2, clientY: 2 });
// check lastDragPosition is unchanged
expect(wrapper.vm.lastDragPosition).toStrictEqual(lastDragPosition);
});
});
describe('when clicking and dragging', () => {
it.each`
description | useTouchEvents
${'with touch events'} | ${true}
${'without touch events'} | ${false}
`('calls scrollTo with correct arguments $description', ({ useTouchEvents }) => {
createComponent(
{
image: 'test.jpg',
imageName: 'test',
},
mockOverlayData,
);
mockRefDimensions(
wrapper.vm.$refs.presentationContainer,
{ width: 10, height: 10 },
{ width: 20, height: 20 },
0,
0,
);
wrapper.element.scrollTo = jest.fn();
return clickDragExplore(
{ clientX: 0, clientY: 0 },
{ clientX: 10, clientY: 10 },
{ useTouchEvents },
).then(() => {
expect(wrapper.element.scrollTo).toHaveBeenCalledTimes(1);
expect(wrapper.element.scrollTo).toHaveBeenCalledWith(-10, -10);
});
});
});
}); });
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