Commit 82c638d9 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'tz-diff-blob-image-viewer' into 'master'

Image Diff Viewing + Download Diff Viewing

See merge request gitlab-org/gitlab-ce!18768
parents 929f12b6 8a23bcc9
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import { mapState, mapGetters, mapActions } from 'vuex'; import { mapState, mapGetters, mapActions } from 'vuex';
import flash from '~/flash'; import flash from '~/flash';
import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import { activityBarViews, viewerTypes } from '../constants'; import { activityBarViews, viewerTypes } from '../constants';
import Editor from '../lib/editor'; import Editor from '../lib/editor';
import ExternalLink from './external_link.vue'; import ExternalLink from './external_link.vue';
...@@ -9,6 +10,7 @@ import ExternalLink from './external_link.vue'; ...@@ -9,6 +10,7 @@ import ExternalLink from './external_link.vue';
export default { export default {
components: { components: {
ContentViewer, ContentViewer,
DiffViewer,
ExternalLink, ExternalLink,
}, },
props: { props: {
...@@ -29,9 +31,18 @@ export default { ...@@ -29,9 +31,18 @@ export default {
shouldHideEditor() { shouldHideEditor() {
return this.file && this.file.binary && !this.file.content; return this.file && this.file.binary && !this.file.content;
}, },
showContentViewer() {
return (
(this.shouldHideEditor || this.file.viewMode === 'preview') &&
(this.viewer !== viewerTypes.mr || !this.file.mrChange)
);
},
showDiffViewer() {
return this.shouldHideEditor && this.file.mrChange && this.viewer === viewerTypes.mr;
},
editTabCSS() { editTabCSS() {
return { return {
active: this.file.viewMode === 'edit', active: this.file.viewMode === 'editor',
}; };
}, },
previewTabCSS() { previewTabCSS() {
...@@ -53,7 +64,7 @@ export default { ...@@ -53,7 +64,7 @@ export default {
if (this.currentActivityView !== activityBarViews.edit) { if (this.currentActivityView !== activityBarViews.edit) {
this.setFileViewMode({ this.setFileViewMode({
file: this.file, file: this.file,
viewMode: 'edit', viewMode: 'editor',
}); });
} }
} }
...@@ -62,7 +73,7 @@ export default { ...@@ -62,7 +73,7 @@ export default {
if (this.currentActivityView !== activityBarViews.edit) { if (this.currentActivityView !== activityBarViews.edit) {
this.setFileViewMode({ this.setFileViewMode({
file: this.file, file: this.file,
viewMode: 'edit', viewMode: 'editor',
}); });
} }
}, },
...@@ -197,7 +208,7 @@ export default { ...@@ -197,7 +208,7 @@ export default {
<a <a
href="javascript:void(0);" href="javascript:void(0);"
role="button" role="button"
@click.prevent="setFileViewMode({ file, viewMode: 'edit' })"> @click.prevent="setFileViewMode({ file, viewMode: 'editor' })">
<template v-if="viewer === $options.viewerTypes.edit"> <template v-if="viewer === $options.viewerTypes.edit">
{{ __('Edit') }} {{ __('Edit') }}
</template> </template>
...@@ -222,7 +233,7 @@ export default { ...@@ -222,7 +233,7 @@ export default {
/> />
</div> </div>
<div <div
v-show="!shouldHideEditor && file.viewMode === 'edit'" v-show="!shouldHideEditor && file.viewMode ==='editor'"
ref="editor" ref="editor"
class="multi-file-editor-holder" class="multi-file-editor-holder"
:class="{ :class="{
...@@ -231,10 +242,18 @@ export default { ...@@ -231,10 +242,18 @@ export default {
> >
</div> </div>
<content-viewer <content-viewer
v-if="shouldHideEditor || file.viewMode === 'preview'" v-if="showContentViewer"
:content="file.content || file.raw" :content="file.content || file.raw"
:path="file.rawPath || file.path" :path="file.rawPath || file.path"
:file-size="file.size" :file-size="file.size"
:project-path="file.projectId"/> :project-path="file.projectId"/>
<diff-viewer
v-if="showDiffViewer"
:diff-mode="file.mrChange.diffMode"
:new-path="file.mrChange.new_path"
:new-sha="currentMergeRequest.sha"
:old-path="file.mrChange.old_path"
:old-sha="currentMergeRequest.baseCommitSha"
:project-path="file.projectId"/>
</div> </div>
</template> </template>
...@@ -21,6 +21,13 @@ export const viewerTypes = { ...@@ -21,6 +21,13 @@ export const viewerTypes = {
diff: 'diff', diff: 'diff',
}; };
export const diffModes = {
replaced: 'replaced',
new: 'new',
deleted: 'deleted',
renamed: 'renamed',
};
export const rightSidebarViews = { export const rightSidebarViews = {
pipelines: 'pipelines-list', pipelines: 'pipelines-list',
jobsDetail: 'jobs-detail', jobsDetail: 'jobs-detail',
......
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
import * as types from '../mutation_types'; import * as types from '../mutation_types';
import { diffModes } from '../../constants';
export default { export default {
[types.SET_FILE_ACTIVE](state, { path, active }) { [types.SET_FILE_ACTIVE](state, { path, active }) {
...@@ -85,8 +86,19 @@ export default { ...@@ -85,8 +86,19 @@ export default {
}); });
}, },
[types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) { [types.SET_FILE_MERGE_REQUEST_CHANGE](state, { file, mrChange }) {
let diffMode = diffModes.replaced;
if (mrChange.new_file) {
diffMode = diffModes.new;
} else if (mrChange.deleted_file) {
diffMode = diffModes.deleted;
} else if (mrChange.renamed_file) {
diffMode = diffModes.renamed;
}
Object.assign(state.entries[file.path], { Object.assign(state.entries[file.path], {
mrChange, mrChange: {
...mrChange,
diffMode,
},
}); });
}, },
[types.SET_FILE_VIEWMODE](state, { file, viewMode }) { [types.SET_FILE_VIEWMODE](state, { file, viewMode }) {
......
...@@ -39,7 +39,7 @@ export const dataStructure = () => ({ ...@@ -39,7 +39,7 @@ export const dataStructure = () => ({
editorColumn: 1, editorColumn: 1,
fileLanguage: '', fileLanguage: '',
eol: '', eol: '',
viewMode: 'edit', viewMode: 'editor',
previewMode: null, previewMode: null,
size: 0, size: 0,
parentPath: null, parentPath: null,
......
...@@ -32,7 +32,10 @@ export default { ...@@ -32,7 +32,10 @@ export default {
<div class="file-container"> <div class="file-container">
<div class="file-content"> <div class="file-content">
<p class="prepend-top-10 file-info"> <p class="prepend-top-10 file-info">
{{ fileName }} ({{ fileSizeReadable }}) {{ fileName }}
<template v-if="fileSize > 0">
({{ fileSizeReadable }})
</template>
</p> </p>
<a <a
:href="path" :href="path"
......
<script> <script>
import _ from 'underscore';
import { numberToHumanSize } from '../../../../lib/utils/number_utils'; import { numberToHumanSize } from '../../../../lib/utils/number_utils';
export default { export default {
...@@ -12,6 +13,10 @@ export default { ...@@ -12,6 +13,10 @@ export default {
required: false, required: false,
default: 0, default: 0,
}, },
renderInfo: {
type: Boolean,
default: true,
},
}, },
data() { data() {
return { return {
...@@ -26,14 +31,34 @@ export default { ...@@ -26,14 +31,34 @@ export default {
return numberToHumanSize(this.fileSize); return numberToHumanSize(this.fileSize);
}, },
}, },
beforeDestroy() {
window.removeEventListener('resize', this.resizeThrottled, false);
},
mounted() {
// The onImgLoad may have happened before the control was actually mounted
this.onImgLoad();
this.resizeThrottled = _.throttle(this.onImgLoad, 400);
window.addEventListener('resize', this.resizeThrottled, false);
},
methods: { methods: {
onImgLoad() { onImgLoad() {
const contentImg = this.$refs.contentImg; const contentImg = this.$refs.contentImg;
if (contentImg) {
this.isZoomable = this.isZoomable =
contentImg.naturalWidth > contentImg.width || contentImg.naturalHeight > contentImg.height; contentImg.naturalWidth > contentImg.width ||
contentImg.naturalHeight > contentImg.height;
this.width = contentImg.naturalWidth; this.width = contentImg.naturalWidth;
this.height = contentImg.naturalHeight; this.height = contentImg.naturalHeight;
this.$emit('imgLoaded', {
width: this.width,
height: this.height,
renderedWidth: contentImg.clientWidth,
renderedHeight: contentImg.clientHeight,
});
}
}, },
onImgClick() { onImgClick() {
if (this.isZoomable) this.isZoomed = !this.isZoomed; if (this.isZoomable) this.isZoomed = !this.isZoomed;
...@@ -47,20 +72,22 @@ export default { ...@@ -47,20 +72,22 @@ export default {
<div class="file-content image_file"> <div class="file-content image_file">
<img <img
ref="contentImg" ref="contentImg"
:class="{ 'isZoomable': isZoomable, 'isZoomed': isZoomed }" :class="{ 'is-zoomable': isZoomable, 'is-zoomed': isZoomed }"
:src="path" :src="path"
:alt="path" :alt="path"
@load="onImgLoad" @load="onImgLoad"
@click="onImgClick"/> @click="onImgClick"/>
<p class="file-info prepend-top-10"> <p
v-if="renderInfo"
class="file-info prepend-top-10">
<template v-if="fileSize>0"> <template v-if="fileSize>0">
{{ fileSizeReadable }} {{ fileSizeReadable }}
</template> </template>
<template v-if="fileSize>0 && width && height"> <template v-if="fileSize>0 && width && height">
- |
</template> </template>
<template v-if="width && height"> <template v-if="width && height">
{{ width }} x {{ height }} W: {{ width }} | H: {{ height }}
</template> </template>
</p> </p>
</div> </div>
......
export const diffModes = {
replaced: 'replaced',
new: 'new',
deleted: 'deleted',
renamed: 'renamed',
};
export const imageViewMode = {
twoup: 'twoup',
swipe: 'swipe',
onion: 'onion',
};
<script>
import { viewerInformationForPath } from '../content_viewer/lib/viewer_utils';
import ImageDiffViewer from './viewers/image_diff_viewer.vue';
import DownloadDiffViewer from './viewers/download_diff_viewer.vue';
export default {
props: {
diffMode: {
type: String,
required: true,
},
newPath: {
type: String,
required: true,
},
newSha: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
oldSha: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
computed: {
viewer() {
if (!this.newPath) return null;
const previewInfo = viewerInformationForPath(this.newPath);
if (!previewInfo) return DownloadDiffViewer;
switch (previewInfo.id) {
case 'image':
return ImageDiffViewer;
default:
return DownloadDiffViewer;
}
},
fullOldPath() {
return `${gon.relative_url_root}/${this.projectPath}/raw/${this.oldSha}/${this.oldPath}`;
},
fullNewPath() {
return `${gon.relative_url_root}/${this.projectPath}/raw/${this.newSha}/${this.newPath}`;
},
},
};
</script>
<template>
<div
class="diff-file preview-container"
v-if="viewer">
<component
:is="viewer"
:diff-mode="diffMode"
:new-path="fullNewPath"
:old-path="fullOldPath"
:project-path="projectPath"
/>
</div>
</template>
<script>
import DownloadViewer from '../../content_viewer/viewers/download_viewer.vue';
import { diffModes } from '../constants';
export default {
components: {
DownloadViewer,
},
props: {
diffMode: {
type: String,
required: true,
},
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
diffModes,
};
</script>
<template>
<div class="diff-file-container">
<div class="diff-viewer">
<div
v-if="diffMode === $options.diffModes.replaced"
class="two-up view row">
<div class="col-sm-6 deleted">
<download-viewer
:path="oldPath"
:project-path="projectPath"
/>
</div>
<div class="col-sm-6 added">
<download-viewer
:path="newPath"
:project-path="projectPath"
/>
</div>
</div>
<div
v-else-if="diffMode === $options.diffModes.new"
class="added">
<download-viewer
:path="newPath"
:project-path="projectPath"
/>
</div>
<div
v-else
class="deleted">
<download-viewer
:path="oldPath"
:project-path="projectPath"
/>
</div>
</div>
</div>
</template>
<script>
import { pixeliseValue } from '../../../lib/utils/dom_utils';
import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue';
export default {
components: {
ImageViewer,
},
props: {
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
onionMaxWidth: undefined,
onionMaxHeight: undefined,
onionOldImgInfo: null,
onionNewImgInfo: null,
onionDraggerPos: 0,
onionOpacity: 1,
dragging: false,
};
},
computed: {
onionMaxPixelWidth() {
return pixeliseValue(this.onionMaxWidth);
},
onionMaxPixelHeight() {
return pixeliseValue(this.onionMaxHeight);
},
onionDraggerPixelPos() {
return pixeliseValue(this.onionDraggerPos);
},
},
beforeDestroy() {
document.body.removeEventListener('mouseup', this.stopDrag);
this.$refs.dragger.removeEventListener('mousedown', this.startDrag);
},
methods: {
dragMove(e) {
if (!this.dragging) return;
const left = e.pageX - this.$refs.dragTrack.getBoundingClientRect().left;
const dragTrackWidth =
this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100;
let leftValue = left;
if (leftValue < 0) leftValue = 0;
if (leftValue > dragTrackWidth) leftValue = dragTrackWidth;
this.onionOpacity = left / dragTrackWidth;
this.onionDraggerPos = leftValue;
},
startDrag() {
this.dragging = true;
document.body.style.userSelect = 'none';
document.body.addEventListener('mousemove', this.dragMove);
},
stopDrag() {
this.dragging = false;
document.body.style.userSelect = '';
document.body.removeEventListener('mousemove', this.dragMove);
},
prepareOnionSkin() {
if (this.onionOldImgInfo && this.onionNewImgInfo) {
this.onionMaxWidth = Math.max(
this.onionOldImgInfo.renderedWidth,
this.onionNewImgInfo.renderedWidth,
);
this.onionMaxHeight = Math.max(
this.onionOldImgInfo.renderedHeight,
this.onionNewImgInfo.renderedHeight,
);
this.onionOpacity = 1;
this.onionDraggerPos =
this.$refs.dragTrack.clientWidth - this.$refs.dragger.clientWidth || 100;
document.body.addEventListener('mouseup', this.stopDrag);
}
},
onionNewImgLoaded(imgInfo) {
this.onionNewImgInfo = imgInfo;
this.prepareOnionSkin();
},
onionOldImgLoaded(imgInfo) {
this.onionOldImgInfo = imgInfo;
this.prepareOnionSkin();
},
},
};
</script>
<template>
<div class="onion-skin view">
<div
class="onion-skin-frame"
:style="{
'width': onionMaxPixelWidth,
'height': onionMaxPixelHeight,
'user-select': dragging === true ? 'none' : '',
}">
<div
class="frame deleted"
:style="{
'width': onionMaxPixelWidth,
'height': onionMaxPixelHeight,
}">
<image-viewer
key="onionOldImg"
:render-info="false"
:path="oldPath"
:project-path="projectPath"
@imgLoaded="onionOldImgLoaded"
/>
</div>
<div
class="added frame"
ref="addedFrame"
:style="{
'opacity': onionOpacity,
'width': onionMaxPixelWidth,
'height': onionMaxPixelHeight,
}">
<image-viewer
key="onionNewImg"
:render-info="false"
:path="newPath"
:project-path="projectPath"
@imgLoaded="onionNewImgLoaded"
/>
</div>
<div class="controls">
<div class="transparent"></div>
<div
class="drag-track"
ref="dragTrack"
@mousedown="startDrag"
@mouseup="stopDrag">
<div
class="dragger"
ref="dragger"
:style="{ 'left': onionDraggerPixelPos }">
</div>
</div>
<div class="opaque"></div>
</div>
</div>
</div>
</template>
<script>
import _ from 'underscore';
import { pixeliseValue } from '../../../lib/utils/dom_utils';
import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue';
export default {
components: {
ImageViewer,
},
props: {
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
dragging: false,
swipeOldImgInfo: null,
swipeNewImgInfo: null,
swipeMaxWidth: undefined,
swipeMaxHeight: undefined,
swipeBarPos: 1,
swipeWrapWidth: undefined,
};
},
computed: {
swipeMaxPixelWidth() {
return pixeliseValue(this.swipeMaxWidth);
},
swipeMaxPixelHeight() {
return pixeliseValue(this.swipeMaxHeight);
},
swipeWrapPixelWidth() {
return pixeliseValue(this.swipeWrapWidth);
},
swipeBarPixelPos() {
return pixeliseValue(this.swipeBarPos);
},
},
beforeDestroy() {
window.removeEventListener('resize', this.resizeThrottled, false);
document.body.removeEventListener('mouseup', this.stopDrag);
document.body.removeEventListener('mousemove', this.dragMove);
},
mounted() {
window.addEventListener('resize', this.resize, false);
},
methods: {
dragMove(e) {
if (!this.dragging) return;
let leftValue = e.pageX - this.$refs.swipeFrame.getBoundingClientRect().left;
const spaceLeft = 20;
const { clientWidth } = this.$refs.swipeFrame;
if (leftValue <= 0) {
leftValue = 0;
} else if (leftValue > clientWidth - spaceLeft) {
leftValue = clientWidth - spaceLeft;
}
this.swipeWrapWidth = this.swipeMaxWidth - leftValue;
this.swipeBarPos = leftValue;
},
startDrag() {
this.dragging = true;
document.body.style.userSelect = 'none';
document.body.addEventListener('mousemove', this.dragMove);
},
stopDrag() {
this.dragging = false;
document.body.style.userSelect = '';
document.body.removeEventListener('mousemove', this.dragMove);
},
prepareSwipe() {
if (this.swipeOldImgInfo && this.swipeNewImgInfo) {
// Add 2 for border width
this.swipeMaxWidth =
Math.max(this.swipeOldImgInfo.renderedWidth, this.swipeNewImgInfo.renderedWidth) + 2;
this.swipeWrapWidth = this.swipeMaxWidth;
this.swipeMaxHeight =
Math.max(this.swipeOldImgInfo.renderedHeight, this.swipeNewImgInfo.renderedHeight) + 2;
document.body.addEventListener('mouseup', this.stopDrag);
}
},
swipeNewImgLoaded(imgInfo) {
this.swipeNewImgInfo = imgInfo;
this.prepareSwipe();
},
swipeOldImgLoaded(imgInfo) {
this.swipeOldImgInfo = imgInfo;
this.prepareSwipe();
},
resize: _.throttle(function throttledResize() {
this.swipeBarPos = 0;
}, 400),
},
};
</script>
<template>
<div class="swipe view">
<div
class="swipe-frame"
ref="swipeFrame"
:style="{
'width': swipeMaxPixelWidth,
'height': swipeMaxPixelHeight,
}">
<div class="frame deleted">
<image-viewer
key="swipeOldImg"
ref="swipeOldImg"
:render-info="false"
:path="oldPath"
:project-path="projectPath"
@imgLoaded="swipeOldImgLoaded"
/>
</div>
<div
class="swipe-wrap"
ref="swipeWrap"
:style="{
'width': swipeWrapPixelWidth,
'height': swipeMaxPixelHeight,
}">
<div class="frame added">
<image-viewer
key="swipeNewImg"
:render-info="false"
:path="newPath"
:project-path="projectPath"
@imgLoaded="swipeNewImgLoaded"
/>
</div>
</div>
<span
class="swipe-bar"
ref="swipeBar"
@mousedown="startDrag"
@mouseup="stopDrag"
:style="{ 'left': swipeBarPixelPos }">
<span class="top-handle"></span>
<span class="bottom-handle"></span>
</span>
</div>
</div>
</template>
<script>
import ImageViewer from '../../../content_viewer/viewers/image_viewer.vue';
export default {
components: {
ImageViewer,
},
props: {
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
};
</script>
<template>
<div class="two-up view row">
<div class="col-sm-6 frame deleted">
<image-viewer
:path="oldPath"
:project-path="projectPath"
/>
</div>
<div class="col-sm-6 frame added">
<image-viewer
:path="newPath"
:project-path="projectPath"
/>
</div>
</div>
</template>
<script>
import ImageViewer from '../../content_viewer/viewers/image_viewer.vue';
import TwoUpViewer from './image_diff/two_up_viewer.vue';
import SwipeViewer from './image_diff/swipe_viewer.vue';
import OnionSkinViewer from './image_diff/onion_skin_viewer.vue';
import { diffModes, imageViewMode } from '../constants';
export default {
components: {
ImageViewer,
TwoUpViewer,
SwipeViewer,
OnionSkinViewer,
},
props: {
diffMode: {
type: String,
required: true,
},
newPath: {
type: String,
required: true,
},
oldPath: {
type: String,
required: true,
},
projectPath: {
type: String,
required: false,
default: '',
},
},
data() {
return {
mode: imageViewMode.twoup,
};
},
methods: {
changeMode(newMode) {
this.mode = newMode;
},
},
diffModes,
imageViewMode,
};
</script>
<template>
<div class="diff-file-container">
<div
class="diff-viewer"
v-if="diffMode === $options.diffModes.replaced">
<div class="image js-replaced-image">
<two-up-viewer
v-if="mode === $options.imageViewMode.twoup"
v-bind="$props"/>
<swipe-viewer
v-else-if="mode === $options.imageViewMode.swipe"
v-bind="$props"/>
<onion-skin-viewer
v-else-if="mode === $options.imageViewMode.onion"
v-bind="$props"/>
</div>
<div class="view-modes">
<ul class="view-modes-menu">
<li
:class="{
active: mode === $options.imageViewMode.twoup
}"
@click="changeMode($options.imageViewMode.twoup)">
{{ s__('ImageDiffViewer|2-up') }}
</li>
<li
:class="{
active: mode === $options.imageViewMode.swipe
}"
@click="changeMode($options.imageViewMode.swipe)">
{{ s__('ImageDiffViewer|Swipe') }}
</li>
<li
:class="{
active: mode === $options.imageViewMode.onion
}"
@click="changeMode($options.imageViewMode.onion)">
{{ s__('ImageDiffViewer|Onion skin') }}
</li>
</ul>
</div>
<div class="note-container"></div>
</div>
<div
v-else-if="diffMode === $options.diffModes.new"
class="diff-viewer added">
<image-viewer
:path="newPath"
:project-path="projectPath"
/>
</div>
<div
v-else
class="diff-viewer deleted">
<image-viewer
:path="oldPath"
:project-path="projectPath"
/>
</div>
</div>
</template>
export function pixeliseValue(val) {
return val ? `${val}px` : '';
}
export default {};
...@@ -400,3 +400,51 @@ span.idiff { ...@@ -400,3 +400,51 @@ span.idiff {
color: $common-gray-light; color: $common-gray-light;
border: 1px solid $common-gray-light; border: 1px solid $common-gray-light;
} }
.preview-container {
height: 100%;
overflow: auto;
.file-container {
background-color: $gray-darker;
display: flex;
height: 100%;
align-items: center;
justify-content: center;
text-align: center;
.file-content {
padding: $gl-padding;
max-width: 100%;
max-height: 100%;
img {
max-width: 90%;
max-height: 70vh;
}
.is-zoomable {
cursor: pointer;
cursor: zoom-in;
&.is-zoomed {
cursor: pointer;
cursor: zoom-out;
max-width: none;
max-height: none;
margin-right: $gl-padding;
}
}
}
.file-info {
font-size: $label-font-size;
color: $diff-image-info-color;
}
}
.md-previewer {
padding: $gl-padding;
}
}
...@@ -189,8 +189,22 @@ ...@@ -189,8 +189,22 @@
img { img {
border: 1px solid $white-light; border: 1px solid $white-light;
background-image: linear-gradient(45deg, $border-color 25%, transparent 25%, transparent 75%, $border-color 75%, $border-color 100%), background-image: linear-gradient(
linear-gradient(45deg, $border-color 25%, transparent 25%, transparent 75%, $border-color 75%, $border-color 100%); 45deg,
$border-color 25%,
transparent 25%,
transparent 75%,
$border-color 75%,
$border-color 100%
),
linear-gradient(
45deg,
$border-color 25%,
transparent 25%,
transparent 75%,
$border-color 75%,
$border-color 100%
);
background-size: 10px 10px; background-size: 10px 10px;
background-position: 0 0, 5px 5px; background-position: 0 0, 5px 5px;
max-width: 100%; max-width: 100%;
...@@ -395,6 +409,69 @@ ...@@ -395,6 +409,69 @@
.line_content { .line_content {
white-space: pre-wrap; white-space: pre-wrap;
} }
.diff-file-container {
.frame.deleted {
border: 0;
background-color: inherit;
.image_file img {
border: 1px solid $deleted;
}
}
.frame.added {
border: 0;
background-color: inherit;
.image_file img {
border: 1px solid $added;
}
}
.swipe.view,
.onion-skin.view {
.swipe-wrap {
top: 0;
right: 0;
}
.frame.deleted {
top: 0;
right: 0;
}
.swipe-bar {
top: 0;
.top-handle {
top: -14px;
left: -7px;
}
.bottom-handle {
bottom: -14px;
left: -7px;
}
}
.file-container {
display: inline-block;
.file-content {
padding: 0;
img {
max-width: none;
}
}
}
}
.onion-skin.view .controls {
bottom: -25px;
}
}
} }
.file-content .diff-file { .file-content .diff-file {
...@@ -536,7 +613,7 @@ ...@@ -536,7 +613,7 @@
margin-right: 0; margin-right: 0;
border-color: $white-light; border-color: $white-light;
cursor: pointer; cursor: pointer;
transition: all .1s ease-out; transition: all 0.1s ease-out;
@for $i from 1 through 4 { @for $i from 1 through 4 {
&:nth-child(#{$i}) { &:nth-child(#{$i}) {
...@@ -563,7 +640,7 @@ ...@@ -563,7 +640,7 @@
height: 24px; height: 24px;
border-radius: 50%; border-radius: 50%;
padding: 0; padding: 0;
transition: transform .1s ease-out; transition: transform 0.1s ease-out;
z-index: 100; z-index: 100;
.collapse-icon { .collapse-icon {
...@@ -708,11 +785,35 @@ ...@@ -708,11 +785,35 @@
width: 100%; width: 100%;
height: 10px; height: 10px;
background-color: $white-light; background-color: $white-light;
background-image: linear-gradient(45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), background-image: linear-gradient(
linear-gradient(225deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), 45deg,
linear-gradient(135deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%), transparent,
linear-gradient(-45deg, transparent, transparent 73%, $diff-jagged-border-gradient-color 75%, $white-light 80%); transparent 73%,
background-position: 5px 5px,0 5px,0 5px,5px 5px; $diff-jagged-border-gradient-color 75%,
$white-light 80%
),
linear-gradient(
225deg,
transparent,
transparent 73%,
$diff-jagged-border-gradient-color 75%,
$white-light 80%
),
linear-gradient(
135deg,
transparent,
transparent 73%,
$diff-jagged-border-gradient-color 75%,
$white-light 80%
),
linear-gradient(
-45deg,
transparent,
transparent 73%,
$diff-jagged-border-gradient-color 75%,
$white-light 80%
);
background-position: 5px 5px, 0 5px, 0 5px, 5px 5px;
background-size: 10px 10px; background-size: 10px 10px;
background-repeat: repeat; background-repeat: repeat;
} }
...@@ -750,11 +851,16 @@ ...@@ -750,11 +851,16 @@
.frame.click-to-comment { .frame.click-to-comment {
position: relative; position: relative;
cursor: image-url('illustrations/image_comment_light_cursor.svg') cursor: image-url('illustrations/image_comment_light_cursor.svg')
$image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; $image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
// Retina cursor // Retina cursor
cursor: -webkit-image-set(image-url('illustrations/image_comment_light_cursor.svg') 1x, image-url('illustrations/image_comment_light_cursor@2x.svg') 2x) cursor: -webkit-image-set(
$image-comment-cursor-left-offset $image-comment-cursor-top-offset, auto; image-url('illustrations/image_comment_light_cursor.svg') 1x,
image-url('illustrations/image_comment_light_cursor@2x.svg') 2x
)
$image-comment-cursor-left-offset $image-comment-cursor-top-offset,
auto;
.comment-indicator { .comment-indicator {
position: absolute; position: absolute;
...@@ -840,7 +946,7 @@ ...@@ -840,7 +946,7 @@
.diff-notes-collapse, .diff-notes-collapse,
.note, .note,
.discussion-reply-holder, { .discussion-reply-holder {
display: none; display: none;
} }
......
...@@ -335,7 +335,6 @@ ...@@ -335,7 +335,6 @@
img { img {
max-width: 90%; max-width: 90%;
max-height: 90%;
} }
.isZoomable { .isZoomable {
......
---
title: Web IDE supports now Image + Download Diff Viewing
merge_request: 18768
author:
type: added
...@@ -152,6 +152,53 @@ describe('IDE store file mutations', () => { ...@@ -152,6 +152,53 @@ describe('IDE store file mutations', () => {
expect(localFile.mrChange.diff).toBe('ABC'); expect(localFile.mrChange.diff).toBe('ABC');
}); });
it('has diffMode replaced by default', () => {
mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, {
file: localFile,
mrChange: {
diff: 'ABC',
},
});
expect(localFile.mrChange.diffMode).toBe('replaced');
});
it('has diffMode new', () => {
mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, {
file: localFile,
mrChange: {
diff: 'ABC',
new_file: true,
},
});
expect(localFile.mrChange.diffMode).toBe('new');
});
it('has diffMode deleted', () => {
mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, {
file: localFile,
mrChange: {
diff: 'ABC',
deleted_file: true,
},
});
expect(localFile.mrChange.diffMode).toBe('deleted');
});
it('has diffMode renamed', () => {
mutations.SET_FILE_MERGE_REQUEST_CHANGE(localState, {
file: localFile,
mrChange: {
diff: 'ABC',
renamed_file: true,
},
});
expect(localFile.mrChange.diffMode).toBe('renamed');
});
}); });
describe('DISCARD_FILE_CHANGES', () => { describe('DISCARD_FILE_CHANGES', () => {
......
...@@ -2,3 +2,6 @@ export const FIXTURES_PATH = '/base/spec/javascripts/fixtures'; ...@@ -2,3 +2,6 @@ export const FIXTURES_PATH = '/base/spec/javascripts/fixtures';
export const TEST_HOST = 'http://test.host'; export const TEST_HOST = 'http://test.host';
export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/one_white_pixel.png`; export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/one_white_pixel.png`;
export const GREEN_BOX_IMAGE_URL = `${FIXTURES_PATH}/images/green_box.png`;
export const RED_BOX_IMAGE_URL = `${FIXTURES_PATH}/images/red_box.png`;
...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; ...@@ -3,6 +3,7 @@ import MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import contentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; import contentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper'; import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants';
describe('ContentViewer', () => { describe('ContentViewer', () => {
let vm; let vm;
...@@ -41,12 +42,12 @@ describe('ContentViewer', () => { ...@@ -41,12 +42,12 @@ describe('ContentViewer', () => {
it('renders image preview', done => { it('renders image preview', done => {
createComponent({ createComponent({
path: 'test.jpg', path: GREEN_BOX_IMAGE_URL,
fileSize: 1024, fileSize: 1024,
}); });
setTimeout(() => { setTimeout(() => {
expect(vm.$el.querySelector('.image_file img').getAttribute('src')).toBe('test.jpg'); expect(vm.$el.querySelector('.image_file img').getAttribute('src')).toBe(GREEN_BOX_IMAGE_URL);
done(); done();
}); });
...@@ -59,9 +60,8 @@ describe('ContentViewer', () => { ...@@ -59,9 +60,8 @@ describe('ContentViewer', () => {
}); });
setTimeout(() => { setTimeout(() => {
expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain( expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain('test.abc');
'test.abc (1.00 KiB)', expect(vm.$el.querySelector('.file-info').textContent.trim()).toContain('(1.00 KiB)');
);
expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toContain('Download'); expect(vm.$el.querySelector('.btn.btn-default').textContent.trim()).toContain('Download');
done(); done();
......
import Vue from 'vue';
import diffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants';
describe('DiffViewer', () => {
let vm;
function createComponent(props) {
const DiffViewer = Vue.extend(diffViewer);
vm = mountComponent(DiffViewer, props);
}
afterEach(() => {
vm.$destroy();
});
it('renders image diff', done => {
window.gon = {
relative_url_root: '',
};
createComponent({
diffMode: 'replaced',
newPath: GREEN_BOX_IMAGE_URL,
newSha: 'ABC',
oldPath: RED_BOX_IMAGE_URL,
oldSha: 'DEF',
projectPath: '',
});
setTimeout(() => {
expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe(
`//raw/DEF/${RED_BOX_IMAGE_URL}`,
);
expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe(
`//raw/ABC/${GREEN_BOX_IMAGE_URL}`,
);
done();
});
});
it('renders fallback download diff display', done => {
createComponent({
diffMode: 'replaced',
newPath: 'test.abc',
newSha: 'ABC',
oldPath: 'testold.abc',
oldSha: 'DEF',
});
setTimeout(() => {
expect(vm.$el.querySelector('.deleted .file-info').textContent.trim()).toContain(
'testold.abc',
);
expect(vm.$el.querySelector('.deleted .btn.btn-default').textContent.trim()).toContain(
'Download',
);
expect(vm.$el.querySelector('.added .file-info').textContent.trim()).toContain('test.abc');
expect(vm.$el.querySelector('.added .btn.btn-default').textContent.trim()).toContain(
'Download',
);
done();
});
});
});
import Vue from 'vue';
import imageDiffViewer from '~/vue_shared/components/diff_viewer/viewers/image_diff_viewer.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { GREEN_BOX_IMAGE_URL, RED_BOX_IMAGE_URL } from 'spec/test_constants';
describe('ImageDiffViewer', () => {
let vm;
function createComponent(props) {
const ImageDiffViewer = Vue.extend(imageDiffViewer);
vm = mountComponent(ImageDiffViewer, props);
}
const triggerEvent = (eventName, el = vm.$el, clientX = 0) => {
const event = document.createEvent('MouseEvents');
event.initMouseEvent(
eventName,
true,
true,
window,
1,
clientX,
0,
clientX,
0,
false,
false,
false,
false,
0,
null,
);
el.dispatchEvent(event);
};
const dragSlider = (sliderElement, dragPixel = 20) => {
triggerEvent('mousedown', sliderElement);
triggerEvent('mousemove', document.body, dragPixel);
triggerEvent('mouseup', document.body);
};
afterEach(() => {
vm.$destroy();
});
it('renders image diff for replaced', done => {
createComponent({
diffMode: 'replaced',
newPath: GREEN_BOX_IMAGE_URL,
oldPath: RED_BOX_IMAGE_URL,
});
setTimeout(() => {
expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe(
GREEN_BOX_IMAGE_URL,
);
expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe(
RED_BOX_IMAGE_URL,
);
expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('2-up');
expect(vm.$el.querySelector('.view-modes-menu li:nth-child(2)').textContent.trim()).toBe(
'Swipe',
);
expect(vm.$el.querySelector('.view-modes-menu li:nth-child(3)').textContent.trim()).toBe(
'Onion skin',
);
done();
});
});
it('renders image diff for new', done => {
createComponent({
diffMode: 'new',
newPath: GREEN_BOX_IMAGE_URL,
oldPath: '',
});
setTimeout(() => {
expect(vm.$el.querySelector('.added .image_file img').getAttribute('src')).toBe(
GREEN_BOX_IMAGE_URL,
);
done();
});
});
it('renders image diff for deleted', done => {
createComponent({
diffMode: 'deleted',
newPath: '',
oldPath: RED_BOX_IMAGE_URL,
});
setTimeout(() => {
expect(vm.$el.querySelector('.deleted .image_file img').getAttribute('src')).toBe(
RED_BOX_IMAGE_URL,
);
done();
});
});
describe('swipeMode', () => {
beforeEach(done => {
createComponent({
diffMode: 'replaced',
newPath: GREEN_BOX_IMAGE_URL,
oldPath: RED_BOX_IMAGE_URL,
});
setTimeout(() => {
done();
});
});
it('switches to Swipe Mode', done => {
vm.$el.querySelector('.view-modes-menu li:nth-child(2)').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe('Swipe');
done();
});
});
it('drag handler is working', done => {
vm.$el.querySelector('.view-modes-menu li:nth-child(2)').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.swipe-bar').style.left).toBe('1px');
expect(vm.$el.querySelector('.top-handle')).not.toBeNull();
dragSlider(vm.$el.querySelector('.swipe-bar'), 40);
vm.$nextTick(() => {
expect(vm.$el.querySelector('.swipe-bar').style.left).toBe('-20px');
done();
});
});
});
});
describe('onionSkin', () => {
beforeEach(done => {
createComponent({
diffMode: 'replaced',
newPath: GREEN_BOX_IMAGE_URL,
oldPath: RED_BOX_IMAGE_URL,
});
setTimeout(() => {
done();
});
});
it('switches to Onion Skin Mode', done => {
vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.view-modes-menu li.active').textContent.trim()).toBe(
'Onion skin',
);
done();
});
});
it('has working drag handler', done => {
vm.$el.querySelector('.view-modes-menu li:nth-child(3)').click();
vm.$nextTick(() => {
expect(vm.$el.querySelector('.dragger').style.left).toBe('100px');
dragSlider(vm.$el.querySelector('.dragger'));
vm.$nextTick(() => {
expect(vm.$el.querySelector('.dragger').style.left).toBe('20px');
expect(vm.$el.querySelector('.added.frame').style.opacity).toBe('0.2');
done();
});
});
});
});
});
import * as domUtils from '~/vue_shared/components/lib/utils/dom_utils';
describe('domUtils', () => {
describe('pixeliseValue', () => {
it('should add px to a given Number', () => {
expect(domUtils.pixeliseValue(12)).toEqual('12px');
});
it('should not add px to 0', () => {
expect(domUtils.pixeliseValue(0)).toEqual('');
});
});
});
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