From 904a26c57c474e0ed7b43dc37269f69b49240301 Mon Sep 17 00:00:00 2001 From: KN4CK3R Date: Sat, 27 Feb 2021 18:25:00 +0100 Subject: [PATCH] Add Image Diff options in Pull Request Diff view (#14450) Implemented GitHub style image diff --- options/locale/locale_en-US.ini | 3 + templates/repo/diff/image_diff.tmpl | 178 +++++++++++++---------- web_src/js/features/imagediff.js | 206 +++++++++++++++++++++++++++ web_src/js/index.js | 2 + web_src/less/features/imagediff.less | 105 ++++++++++++++ web_src/less/index.less | 1 + 6 files changed, 421 insertions(+), 74 deletions(-) create mode 100644 web_src/js/features/imagediff.js create mode 100644 web_src/less/features/imagediff.less diff --git a/options/locale/locale_en-US.ini b/options/locale/locale_en-US.ini index 8245df754..ee8a7673e 100644 --- a/options/locale/locale_en-US.ini +++ b/options/locale/locale_en-US.ini @@ -1854,6 +1854,9 @@ diff.review.approve = Approve diff.review.reject = Request changes diff.committed_by = committed by diff.protected = Protected +diff.image.side_by_side = Side by Side +diff.image.swipe = Swipe +diff.image.overlay = Overlay releases.desc = Track project versions and downloads. release.releases = Releases diff --git a/templates/repo/diff/image_diff.tmpl b/templates/repo/diff/image_diff.tmpl index eda208d74..01f7e3f8e 100644 --- a/templates/repo/diff/image_diff.tmpl +++ b/templates/repo/diff/image_diff.tmpl @@ -1,79 +1,109 @@ {{ $imagePathOld := printf "%s/%s" .root.BeforeRawPath (EscapePound .file.OldName) }} {{ $imagePathNew := printf "%s/%s" .root.RawPath (EscapePound .file.Name) }} - - - - {{.root.i18n.Tr "repo.diff.file_before"}} - - - {{.root.i18n.Tr "repo.diff.file_after"}} - - - - - {{if or .file.IsDeleted (not .file.IsCreated)}} - - - - {{end}} - - - {{if or .file.IsCreated (not .file.IsDeleted)}} - - - - {{end}} - - {{ $imageInfoBase := (call .root.ImageInfoBase .file.OldName) }} {{ $imageInfoHead := (call .root.ImageInfo .file.Name) }} -{{if or $imageInfoBase $imageInfoHead }} +{{if or $imageInfoBase $imageInfoHead}} - - {{if $imageInfoBase }} - {{ $classWidth := "" }} - {{ $classHeight := "" }} - {{ $classByteSize := "" }} - {{if $imageInfoHead}} - {{if not (eq $imageInfoBase.Width $imageInfoHead.Width)}} - {{ $classWidth = "red" }} - {{end}} - {{if not (eq $imageInfoBase.Height $imageInfoHead.Height)}} - {{ $classHeight = "red" }} - {{end}} - {{if not (eq $imageInfoBase.ByteSize $imageInfoHead.ByteSize)}} - {{ $classByteSize = "red" }} - {{end}} - {{end}} - {{.root.i18n.Tr "repo.diff.file_image_width"}}: {{$imageInfoBase.Width}} -  |  - {{.root.i18n.Tr "repo.diff.file_image_height"}}: {{$imageInfoBase.Height}} -  |  - {{.root.i18n.Tr "repo.diff.file_byte_size"}}: {{FileSize $imageInfoBase.ByteSize}} - {{end}} - - - {{if $imageInfoHead }} - {{ $classWidth := "" }} - {{ $classHeight := "" }} - {{ $classByteSize := "" }} - {{if $imageInfoBase}} - {{if not (eq $imageInfoBase.Width $imageInfoHead.Width)}} - {{ $classWidth = "green" }} - {{end}} - {{if not (eq $imageInfoBase.Height $imageInfoHead.Height)}} - {{ $classHeight = "green" }} - {{end}} - {{if not (eq $imageInfoBase.ByteSize $imageInfoHead.ByteSize)}} - {{ $classByteSize = "green" }} - {{end}} - {{end}} - {{.root.i18n.Tr "repo.diff.file_image_width"}}: {{$imageInfoHead.Width}} -  |  - {{.root.i18n.Tr "repo.diff.file_image_height"}}: {{$imageInfoHead.Height}} -  |  - {{.root.i18n.Tr "repo.diff.file_byte_size"}}: {{FileSize $imageInfoHead.ByteSize}} - {{end}} - - -{{end}} + +
+ +
+
+
+ {{if $imageInfoBase }} + +

{{.root.i18n.Tr "repo.diff.file_before"}}

+ +

+ {{ $classWidth := "" }} + {{ $classHeight := "" }} + {{ $classByteSize := "" }} + {{if $imageInfoHead}} + {{if not (eq $imageInfoBase.Width $imageInfoHead.Width)}} + {{ $classWidth = "red" }} + {{end}} + {{if not (eq $imageInfoBase.Height $imageInfoHead.Height)}} + {{ $classHeight = "red" }} + {{end}} + {{if not (eq $imageInfoBase.ByteSize $imageInfoHead.ByteSize)}} + {{ $classByteSize = "red" }} + {{end}} + {{end}} + {{.root.i18n.Tr "repo.diff.file_image_width"}}: {{$imageInfoBase.Width}} +  |  + {{.root.i18n.Tr "repo.diff.file_image_height"}}: {{$imageInfoBase.Height}} +  |  + {{.root.i18n.Tr "repo.diff.file_byte_size"}}: {{FileSize $imageInfoBase.ByteSize}} +

+
+ {{end}} + {{if $imageInfoHead }} + +

{{.root.i18n.Tr "repo.diff.file_after"}}

+ +

+ {{ $classWidth := "" }} + {{ $classHeight := "" }} + {{ $classByteSize := "" }} + {{if $imageInfoBase}} + {{if not (eq $imageInfoBase.Width $imageInfoHead.Width)}} + {{ $classWidth = "green" }} + {{end}} + {{if not (eq $imageInfoBase.Height $imageInfoHead.Height)}} + {{ $classHeight = "green" }} + {{end}} + {{if not (eq $imageInfoBase.ByteSize $imageInfoHead.ByteSize)}} + {{ $classByteSize = "green" }} + {{end}} + {{end}} + {{.root.i18n.Tr "repo.diff.file_image_width"}}: {{$imageInfoHead.Width}} +  |  + {{.root.i18n.Tr "repo.diff.file_image_height"}}: {{$imageInfoHead.Height}} +  |  + {{.root.i18n.Tr "repo.diff.file_byte_size"}}: {{FileSize $imageInfoHead.ByteSize}} +

+
+ {{end}} +
+
+ {{if and $imageInfoBase $imageInfoHead}} +
+
+
+ + + + + + + + +
+
+
+
+
+
+
+ +
+ + +
+
+
+ {{end}} +
+
+
+ + +{{end}} \ No newline at end of file diff --git a/web_src/js/features/imagediff.js b/web_src/js/features/imagediff.js new file mode 100644 index 000000000..ce7ce8d2a --- /dev/null +++ b/web_src/js/features/imagediff.js @@ -0,0 +1,206 @@ +export default async function initImageDiff() { + function createContext(image1, image2) { + const size1 = { + width: image1 && image1.width || 0, + height: image1 && image1.height || 0 + }; + const size2 = { + width: image2 && image2.width || 0, + height: image2 && image2.height || 0 + }; + const max = { + width: Math.max(size2.width, size1.width), + height: Math.max(size2.height, size1.height) + }; + + return { + image1: $(image1), + image2: $(image2), + size1, + size2, + max, + ratio: [ + Math.floor(max.width - size1.width) / 2, + Math.floor(max.height - size1.height) / 2, + Math.floor(max.width - size2.width) / 2, + Math.floor(max.height - size2.height) / 2 + ] + }; + } + + $('.image-diff').each(function() { + const $container = $(this); + const pathAfter = $container.data('path-after'); + const pathBefore = $container.data('path-before'); + + const imageInfos = [{ + loaded: false, + path: pathAfter, + $image: $container.find('img.image-after') + }, { + loaded: false, + path: pathBefore, + $image: $container.find('img.image-before') + }]; + + for (const info of imageInfos) { + if (info.$image.length > 0) { + info.$image.on('load', () => { + info.loaded = true; + setReadyIfLoaded(); + }); + info.$image.attr('src', info.path); + } else { + info.loaded = true; + setReadyIfLoaded(); + } + } + + const diffContainerWidth = $container.width() - 300; + + function setReadyIfLoaded() { + if (imageInfos[0].loaded && imageInfos[1].loaded) { + initViews(imageInfos[0].$image, imageInfos[1].$image); + } + } + + function initViews($imageAfter, $imageBefore) { + initSideBySide(createContext($imageAfter[0], $imageBefore[0])); + if ($imageAfter.length > 0 && $imageBefore.length > 0) { + initSwipe(createContext($imageAfter[1], $imageBefore[1])); + initOverlay(createContext($imageAfter[2], $imageBefore[2])); + } + + $container.find('> .loader').hide(); + $container.find('> .hide').removeClass('hide'); + } + + function initSideBySide(sizes) { + let factor = 1; + if (sizes.max.width > (diffContainerWidth - 24) / 2) { + factor = (diffContainerWidth - 24) / 2 / sizes.max.width; + } + + sizes.image1.css({ + width: sizes.size1.width * factor, + height: sizes.size1.height * factor + }); + sizes.image1.parent().css({ + margin: `${sizes.ratio[1] * factor + 15}px ${sizes.ratio[0] * factor}px ${sizes.ratio[1] * factor}px`, + width: sizes.size1.width * factor + 2, + height: sizes.size1.height * factor + 2 + }); + sizes.image2.css({ + width: sizes.size2.width * factor, + height: sizes.size2.height * factor + }); + sizes.image2.parent().css({ + margin: `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`, + width: sizes.size2.width * factor + 2, + height: sizes.size2.height * factor + 2 + }); + } + + function initSwipe(sizes) { + let factor = 1; + if (sizes.max.width > diffContainerWidth - 12) { + factor = (diffContainerWidth - 12) / sizes.max.width; + } + + sizes.image1.css({ + width: sizes.size1.width * factor, + height: sizes.size1.height * factor + }); + sizes.image1.parent().css({ + margin: `0px ${sizes.ratio[0] * factor}px`, + width: sizes.size1.width * factor + 2, + height: sizes.size1.height * factor + 2 + }); + sizes.image1.parent().parent().css({ + padding: `${sizes.ratio[1] * factor}px 0 0 0`, + width: sizes.max.width * factor + 2 + }); + sizes.image2.css({ + width: sizes.size2.width * factor, + height: sizes.size2.height * factor + }); + sizes.image2.parent().css({ + margin: `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`, + width: sizes.size2.width * factor + 2, + height: sizes.size2.height * factor + 2 + }); + sizes.image2.parent().parent().css({ + width: sizes.max.width * factor + 2, + height: sizes.max.height * factor + 2 + }); + $container.find('.diff-swipe').css({ + width: sizes.max.width * factor + 2, + height: sizes.max.height * factor + 4 + }); + $container.find('.swipe-bar').on('mousedown', function(e) { + e.preventDefault(); + + const $swipeBar = $(this); + const $swipeFrame = $swipeBar.parent(); + const width = $swipeFrame.width() - $swipeBar.width() - 2; + + $(document).on('mousemove.diff-swipe', (e2) => { + e2.preventDefault(); + + const value = Math.max(0, Math.min(e2.clientX - $swipeFrame.offset().left, width)); + + $swipeBar.css({ + left: value + }); + $container.find('.swipe-container').css({ + width: $swipeFrame.width() - value + }); + $(document).on('mouseup.diff-swipe', () => { + $(document).off('.diff-swipe'); + }); + }); + }); + } + + function initOverlay(sizes) { + let factor = 1; + if (sizes.max.width > diffContainerWidth - 12) { + factor = (diffContainerWidth - 12) / sizes.max.width; + } + + sizes.image1.css({ + width: sizes.size1.width * factor, + height: sizes.size1.height * factor + }); + sizes.image2.css({ + width: sizes.size2.width * factor, + height: sizes.size2.height * factor + }); + sizes.image1.parent().css({ + margin: `${sizes.ratio[1] * factor}px ${sizes.ratio[0] * factor}px`, + width: sizes.size1.width * factor + 2, + height: sizes.size1.height * factor + 2 + }); + sizes.image2.parent().css({ + margin: `${sizes.ratio[3] * factor}px ${sizes.ratio[2] * factor}px`, + width: sizes.size2.width * factor + 2, + height: sizes.size2.height * factor + 2 + }); + sizes.image2.parent().parent().css({ + width: sizes.max.width * factor + 2, + height: sizes.max.height * factor + 2 + }); + $container.find('.onion-skin').css({ + width: sizes.max.width * factor + 2, + height: sizes.max.height * factor + 4 + }); + + const $range = $container.find("input[type='range'"); + const onInput = () => sizes.image1.parent().css({ + opacity: $range.val() / 100 + }); + $range.on('input', onInput); + onInput(); + } + }); +} diff --git a/web_src/js/index.js b/web_src/js/index.js index b65291a26..30af5dea1 100644 --- a/web_src/js/index.js +++ b/web_src/js/index.js @@ -20,6 +20,7 @@ import attachTribute from './features/tribute.js'; import createColorPicker from './features/colorpicker.js'; import createDropzone from './features/dropzone.js'; import initTableSort from './features/tablesort.js'; +import initImageDiff from './features/imagediff.js'; import ActivityTopAuthors from './components/ActivityTopAuthors.vue'; import {initNotificationsTable, initNotificationCount} from './features/notification.js'; import {initStopwatch} from './features/stopwatch.js'; @@ -2693,6 +2694,7 @@ $(document).ready(async () => { initStopwatch(), renderMarkdownContent(), initGithook(), + initImageDiff(), ]); }); diff --git a/web_src/less/features/imagediff.less b/web_src/less/features/imagediff.less new file mode 100644 index 000000000..f38ea98d7 --- /dev/null +++ b/web_src/less/features/imagediff.less @@ -0,0 +1,105 @@ +.image-diff-container { + text-align: center; + padding: 30px 0; + + img { + border: 1px solid var(--color-primary-light-7); + background: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAoAAAAKCAYAAACNMs+9AAAAG0lEQVQYlWN4+vTpf3SMDTAMBYXYBLFpHgoKAeiOf0SGE9kbAAAAAElFTkSuQmCC) right bottom var(--color-primary-light-7); + } + + .before-container { + border: 1px solid var(--color-red); + display: block; + } + + .after-container { + border: 1px solid var(--color-green); + display: block; + } + + .diff-side-by-side { + .side { + display: inline-block; + line-height: 0; + vertical-align: top; + + .side-header { + font-weight: bold; + } + } + } + + .diff-swipe { + margin: auto; + + .swipe-frame { + position: absolute; + + .before-container { + position: absolute; + } + + .swipe-container { + position: absolute; + right: 0; + display: block; + border-left: 2px solid var(--color-secondary-dark-8); + height: 100%; + overflow: hidden; + + .after-container { + position: absolute; + right: 0; + } + } + + .swipe-bar { + z-index: 100; + position: absolute; + height: 100%; + top: 0; + left: 0; + + .handle { + background: var(--color-secondary-dark-8); + left: -5px; + height: 12px; + width: 12px; + position: absolute; + transform: rotate(45deg); + box-sizing: border-box; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; + } + + .top-handle { + top: -12px; + } + + .bottom-handle { + bottom: -14px; + } + } + } + } + + .diff-overlay { + margin: 0 auto; + + .overlay-frame { + margin: 0 auto; + position: relative; + } + + .before-container, + .after-container { + position: absolute; + } + + input { + width: 300px; + } + } +} diff --git a/web_src/less/index.less b/web_src/less/index.less index 598693085..cd70eedef 100644 --- a/web_src/less/index.less +++ b/web_src/less/index.less @@ -5,6 +5,7 @@ @import "./features/gitgraph.less"; @import "./features/animations.less"; @import "./features/heatmap.less"; +@import "./features/imagediff.less"; @import "./markdown/mermaid.less"; @import "./chroma/base.less";